From 132ae758dedcb9e8748a8a0131422025a032427f Mon Sep 17 00:00:00 2001 From: JiWoong Sul Date: Fri, 14 Nov 2025 16:53:41 +0900 Subject: [PATCH] feat: add payment card grouping and analysis --- assets/data/text.json | 92 +++ doc/payment_card_plan.md | 16 + .../add_subscription_controller.dart | 10 + lib/controllers/detail_screen_controller.dart | 11 + lib/controllers/sms_scan_controller.dart | 120 +++- lib/l10n/app_localizations.dart | 50 ++ lib/main.dart | 7 + lib/models/payment_card_model.dart | 33 ++ lib/models/payment_card_model.g.dart | 56 ++ lib/models/payment_card_suggestion.dart | 14 + lib/models/subscription.dart | 18 + lib/models/subscription_model.dart | 4 + lib/models/subscription_model.g.dart | 7 +- lib/providers/payment_card_provider.dart | 124 ++++ lib/providers/subscription_provider.dart | 27 +- lib/routes/app_routes.dart | 5 + lib/screens/analysis_screen.dart | 209 ++++++- lib/screens/detail_screen.dart | 8 + .../payment_card_management_screen.dart | 144 +++++ lib/screens/settings_screen.dart | 44 ++ lib/screens/sms_scan_screen.dart | 30 + lib/services/sms_scan/sms_scan_result.dart | 14 + .../sms_scan/subscription_converter.dart | 17 +- lib/services/sms_scanner.dart | 91 ++- lib/utils/payment_card_utils.dart | 41 ++ lib/utils/subscription_grouping_helper.dart | 155 +++++ .../add_subscription_form.dart | 32 ++ lib/widgets/analysis/event_analysis_card.dart | 538 +++++++++--------- lib/widgets/app_navigator.dart | 6 + lib/widgets/category_header_widget.dart | 126 ---- lib/widgets/detail/detail_form_section.dart | 30 + lib/widgets/detail/detail_header_section.dart | 112 ++++ .../detail/detail_payment_info_section.dart | 196 +++++++ lib/widgets/home_content.dart | 149 +++-- .../payment_card/payment_card_form_sheet.dart | 290 ++++++++++ .../payment_card/payment_card_selector.dart | 142 +++++ .../sms_scan/subscription_card_widget.dart | 131 +++++ lib/widgets/subscription_card.dart | 61 +- lib/widgets/subscription_group_header.dart | 164 ++++++ lib/widgets/subscription_list_widget.dart | 44 +- 40 files changed, 2846 insertions(+), 522 deletions(-) create mode 100644 lib/models/payment_card_model.dart create mode 100644 lib/models/payment_card_model.g.dart create mode 100644 lib/models/payment_card_suggestion.dart create mode 100644 lib/providers/payment_card_provider.dart create mode 100644 lib/screens/payment_card_management_screen.dart create mode 100644 lib/services/sms_scan/sms_scan_result.dart create mode 100644 lib/utils/payment_card_utils.dart create mode 100644 lib/utils/subscription_grouping_helper.dart delete mode 100644 lib/widgets/category_header_widget.dart create mode 100644 lib/widgets/detail/detail_payment_info_section.dart create mode 100644 lib/widgets/payment_card/payment_card_form_sheet.dart create mode 100644 lib/widgets/payment_card/payment_card_selector.dart create mode 100644 lib/widgets/subscription_group_header.dart diff --git a/assets/data/text.json b/assets/data/text.json index 4eb092b..05d28c4 100644 --- a/assets/data/text.json +++ b/assets/data/text.json @@ -29,6 +29,29 @@ "language": "Language", "notifications": "Notifications", "appLock": "App Lock", + "paymentCard": "Payment Card", + "paymentCardManagement": "Payment Card Management", + "paymentCardManagementDescription": "Manage saved cards for subscriptions", + "addPaymentCard": "Add Payment Card", + "editPaymentCard": "Edit Payment Card", + "paymentCardIssuer": "Card Name / Issuer", + "paymentCardLast4": "Last 4 Digits", + "paymentCardColor": "Card Color", + "paymentCardIcon": "Card Icon", + "setAsDefaultCard": "Set as default card", + "paymentCardUnassigned": "Unassigned", + "addNewCard": "Add New Card", + "managePaymentCards": "Manage Cards", + "choosePaymentCard": "Choose Payment Card", + "analysisCardFilterLabel": "Filter by payment card", + "analysisCardFilterAll": "All cards", + "cardDefaultBadge": "Default", + "noPaymentCards": "No payment cards saved yet.", + "detectedPaymentCard": "Card Detected", + "detectedPaymentCardDescription": "@ was detected from SMS.", + "addDetectedPaymentCard": "Add Card", + "paymentCardUnassignedWarning": "Without a card selection this subscription will be saved as \"Unassigned\".", + "areYouSure": "Are you sure?", "notificationPermission": "Notification Permission", "notificationPermissionDesc": "Permission is required to receive notifications", "requestPermission": "Request Permission", @@ -260,6 +283,29 @@ "language": "언어", "notifications": "알림", "appLock": "앱 잠금", + "paymentCard": "결제수단", + "paymentCardManagement": "결제수단 관리", + "paymentCardManagementDescription": "저장된 결제수단을 추가·편집·삭제합니다", + "addPaymentCard": "결제수단 추가", + "editPaymentCard": "결제수단 수정", + "paymentCardIssuer": "카드 이름 / 발급사", + "paymentCardLast4": "마지막 4자리", + "paymentCardColor": "카드 색상", + "paymentCardIcon": "아이콘", + "setAsDefaultCard": "기본 결제수단으로 설정", + "paymentCardUnassigned": "미지정", + "addNewCard": "새 카드 추가", + "managePaymentCards": "결제수단 관리", + "choosePaymentCard": "결제수단 선택", + "analysisCardFilterLabel": "결제수단별 보기", + "analysisCardFilterAll": "모든 결제수단", + "cardDefaultBadge": "기본", + "noPaymentCards": "등록된 결제수단이 없습니다.", + "detectedPaymentCard": "감지된 결제수단", + "detectedPaymentCardDescription": "SMS에서 @ 이(가) 감지되었습니다.", + "addDetectedPaymentCard": "카드 추가", + "paymentCardUnassignedWarning": "결제수단을 선택하지 않으면 '미지정'으로 저장됩니다.", + "areYouSure": "정말 진행하시겠어요?", "notificationPermission": "알림 권한", "notificationPermissionDesc": "알림을 받으려면 권한이 필요합니다", "requestPermission": "권한 요청", @@ -491,6 +537,29 @@ "language": "言語", "notifications": "通知", "appLock": "アプリロック", + "paymentCard": "支払いカード", + "paymentCardManagement": "支払いカード管理", + "paymentCardManagementDescription": "保存済みのカードを追加・編集・削除します", + "addPaymentCard": "カードを追加", + "editPaymentCard": "カードを編集", + "paymentCardIssuer": "カード名 / 発行会社", + "paymentCardLast4": "下4桁", + "paymentCardColor": "カードカラー", + "paymentCardIcon": "アイコン", + "setAsDefaultCard": "既定のカードとして設定", + "paymentCardUnassigned": "未設定", + "addNewCard": "新しいカードを追加", + "managePaymentCards": "カードを管理", + "choosePaymentCard": "支払いカードを選択", + "analysisCardFilterLabel": "支払いカード別に表示", + "analysisCardFilterAll": "すべてのカード", + "cardDefaultBadge": "既定", + "noPaymentCards": "登録されたカードがありません。", + "detectedPaymentCard": "検出されたカード", + "detectedPaymentCardDescription": "SMS から @ が検出されました。", + "addDetectedPaymentCard": "カードを追加", + "paymentCardUnassignedWarning": "カードを選択しない場合は「未設定」として保存されます。", + "areYouSure": "よろしいですか?", "notificationPermission": "通知権限", "notificationPermissionDesc": "通知を受け取るには権限が必要です", "requestPermission": "権限をリクエスト", @@ -711,6 +780,29 @@ "language": "语言", "notifications": "通知", "appLock": "应用锁定", + "paymentCard": "支付卡", + "paymentCardManagement": "支付卡管理", + "paymentCardManagementDescription": "管理已保存的支付卡(新增/编辑/删除)", + "addPaymentCard": "添加支付卡", + "editPaymentCard": "编辑支付卡", + "paymentCardIssuer": "卡名称/发卡行", + "paymentCardLast4": "后四位", + "paymentCardColor": "卡片颜色", + "paymentCardIcon": "图标", + "setAsDefaultCard": "设为默认卡", + "paymentCardUnassigned": "未指定", + "addNewCard": "新增卡片", + "managePaymentCards": "管理卡片", + "choosePaymentCard": "选择支付卡", + "analysisCardFilterLabel": "按支付卡筛选", + "analysisCardFilterAll": "所有支付卡", + "cardDefaultBadge": "默认", + "noPaymentCards": "尚未保存任何支付卡。", + "detectedPaymentCard": "检测到的支付卡", + "detectedPaymentCardDescription": "短信检测到 @。", + "addDetectedPaymentCard": "添加卡片", + "paymentCardUnassignedWarning": "未选择支付卡时将以\"未指定\"保存。", + "areYouSure": "确定要继续吗?", "notificationPermission": "通知权限", "notificationPermissionDesc": "需要权限才能接收通知", "requestPermission": "请求权限", diff --git a/doc/payment_card_plan.md b/doc/payment_card_plan.md index 7ad07d4..6b46e8b 100644 --- a/doc/payment_card_plan.md +++ b/doc/payment_card_plan.md @@ -10,6 +10,16 @@ - 홈 화면에서 카테고리/카드 뷰를 토글하며, 헤더·리스트·분석 카드가 카드 정보를 시각적으로 노출. - 모든 변경은 기존 카테고리 UX를 유지하면서 점진적 도입이 가능해야 함. +## 작업 체크리스트 +1. [x] `SubscriptionModel`에 `paymentCardId` 추가, `PaymentCardModel`/`PaymentCardProvider` 구현, Hive 등록 및 마이그레이션 수행. +2. [x] 결제수단 관리 화면과 구독 추가/편집/SMS 시트에서 사용할 `PaymentCardSelector` + “새 카드 추가” 플로우 구현. +3. [x] 홈·리스트 UI에 카테고리/카드 토글, 그룹 헤더, 카드 Chip 표시, `SubscriptionGroupingHelper` 도입. +4. [x] 구독 상세 화면(헤더, 결제 정보 섹션, 편집 폼)에서 카드 정보 노출 및 수정 기능 연결. +5. [x] SMS 스캔 컨트롤러/리뷰 UI에 카드 추정·선택·저장 로직 추가, 기본값/자동 생성 전략 반영. +6. [x] 분석 화면(파이 차트, 합계 카드, 월별 차트 등)이 카드 필터/데이터에 대응하도록 확장. +7. [x] 설정/내비게이션/로컬라이제이션/접근성 업데이트 및 새 문자열 번역 반영. +8. [ ] QA 플로우: `flutter pub run build_runner build`, `scripts/check.sh`, 다국어·다크모드·태블릿·알림/백업 테스트 완료. + ## 데이터 모델 및 저장소 - `SubscriptionModel`에 `paymentCardId`(필요 시 `displayName`/닉네임) 필드 추가, Hive 어댑터 재생성. 기존 데이터는 `null` 기본값으로 역호환. - 새 `PaymentCardModel` 작성: `id`, `issuerName`(회사명 자유 입력), `last4`, `colorHex`, `iconName` 등을 저장. Hive typeId는 미사용 값을 배정. @@ -91,6 +101,12 @@ 4. 다국어·다크모드·태블릿 해상도에서 UI 붕괴 여부 점검. 5. 알림/위젯/백그라운드 서비스(예: 결제 알림)에서 카드 필드 추가로 인한 크래시가 없는지 Crashlytics/디버그 로그 확인. +#### QA 실행 현황 (2025-11-14) +- ✅ `flutter pub run build_runner build --delete-conflicting-outputs` +- ✅ `scripts/check.sh` +- ✅ `flutter analyze` +- ✅ `flutter test` + ## 분석 및 향후 확장 - 공통 `SubscriptionGroupingHelper`를 만들어 카드/카테고리 그룹 데이터를 모두 생성하게 설계하면 `MonthlyExpenseChartCard`, 파이 차트, 이벤트 카드 등도 카드 필터를 쉽게 지원. - 초기에는 홈 리스트에만 카드 모드를 적용하고, 이후 분석 탭에 “카드별 지출” 섹션을 추가해 순차 배포. diff --git a/lib/controllers/add_subscription_controller.dart b/lib/controllers/add_subscription_controller.dart index 2469396..527b52e 100644 --- a/lib/controllers/add_subscription_controller.dart +++ b/lib/controllers/add_subscription_controller.dart @@ -4,6 +4,7 @@ import 'package:provider/provider.dart'; import 'package:intl/intl.dart'; import '../providers/subscription_provider.dart'; import '../providers/category_provider.dart'; +import '../providers/payment_card_provider.dart'; import '../services/sms_service.dart'; import '../services/subscription_url_matcher.dart'; import '../widgets/common/snackbar/app_snackbar.dart'; @@ -31,6 +32,7 @@ class AddSubscriptionController { DateTime? nextBillingDate; bool isLoading = false; String? selectedCategoryId; + String? selectedPaymentCardId; // Event State bool isEventActive = false; @@ -126,6 +128,13 @@ class AddSubscriptionController { // Localizations가 아직 준비되지 않은 경우 기본값 유지 } + // 기본 결제수단 설정 + try { + final paymentCardProvider = + Provider.of(context, listen: false); + selectedPaymentCardId = paymentCardProvider.defaultCard?.id; + } catch (_) {} + // 애니메이션 시작 animationController!.forward(); } @@ -503,6 +512,7 @@ class AddSubscriptionController { nextBillingDate: adjustedNext, websiteUrl: websiteUrlController.text.trim(), categoryId: selectedCategoryId, + paymentCardId: selectedPaymentCardId, currency: currency, isEventActive: isEventActive, eventStartDate: eventStartDate, diff --git a/lib/controllers/detail_screen_controller.dart b/lib/controllers/detail_screen_controller.dart index 11ca135..7d01ed2 100644 --- a/lib/controllers/detail_screen_controller.dart +++ b/lib/controllers/detail_screen_controller.dart @@ -34,6 +34,7 @@ class DetailScreenController extends ChangeNotifier { late String _billingCycle; late DateTime _nextBillingDate; String? _selectedCategoryId; + String? _selectedPaymentCardId; late String _currency; bool _isLoading = false; @@ -46,6 +47,7 @@ class DetailScreenController extends ChangeNotifier { String get billingCycle => _billingCycle; DateTime get nextBillingDate => _nextBillingDate; String? get selectedCategoryId => _selectedCategoryId; + String? get selectedPaymentCardId => _selectedPaymentCardId; String get currency => _currency; bool get isLoading => _isLoading; bool get isEventActive => _isEventActive; @@ -74,6 +76,13 @@ class DetailScreenController extends ChangeNotifier { } } + set selectedPaymentCardId(String? value) { + if (_selectedPaymentCardId != value) { + _selectedPaymentCardId = value; + notifyListeners(); + } + } + set currency(String value) { if (_currency != value) { _currency = value; @@ -153,6 +162,7 @@ class DetailScreenController extends ChangeNotifier { _billingCycle = subscription.billingCycle; _nextBillingDate = subscription.nextBillingDate; _selectedCategoryId = subscription.categoryId; + _selectedPaymentCardId = subscription.paymentCardId; _currency = subscription.currency; // Event State 초기화 @@ -415,6 +425,7 @@ class DetailScreenController extends ChangeNotifier { BillingDateUtil.ensureFutureDate(originalDateOnly, _billingCycle); subscription.nextBillingDate = adjustedNext; subscription.categoryId = _selectedCategoryId; + subscription.paymentCardId = _selectedPaymentCardId; subscription.currency = _currency; // 이벤트 정보 업데이트 diff --git a/lib/controllers/sms_scan_controller.dart b/lib/controllers/sms_scan_controller.dart index 8cf526c..d5d0148 100644 --- a/lib/controllers/sms_scan_controller.dart +++ b/lib/controllers/sms_scan_controller.dart @@ -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 _scannedSubscriptions = []; List 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 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(context, listen: false); final categoryProvider = Provider.of(context, listen: false); + final paymentCardProvider = + Provider.of(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(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(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; + } } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 2fbec7f..942da47 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -63,6 +63,56 @@ class AppLocalizations { String get notifications => _localizedStrings['notifications'] ?? 'Notifications'; String get appLock => _localizedStrings['appLock'] ?? 'App Lock'; + String get paymentCard => _localizedStrings['paymentCard'] ?? 'Payment Card'; + String get paymentCardManagement => + _localizedStrings['paymentCardManagement'] ?? 'Payment Card Management'; + String get paymentCardManagementDescription => + _localizedStrings['paymentCardManagementDescription'] ?? + 'Manage saved cards for subscriptions'; + String get addPaymentCard => + _localizedStrings['addPaymentCard'] ?? 'Add Payment Card'; + String get editPaymentCard => + _localizedStrings['editPaymentCard'] ?? 'Edit Payment Card'; + String get paymentCardIssuer => + _localizedStrings['paymentCardIssuer'] ?? 'Card Name / Issuer'; + String get paymentCardLast4 => + _localizedStrings['paymentCardLast4'] ?? 'Last 4 Digits'; + String get paymentCardColor => + _localizedStrings['paymentCardColor'] ?? 'Card Color'; + String get paymentCardIcon => + _localizedStrings['paymentCardIcon'] ?? 'Card Icon'; + String get setAsDefaultCard => + _localizedStrings['setAsDefaultCard'] ?? 'Set as default card'; + String get paymentCardUnassigned => + _localizedStrings['paymentCardUnassigned'] ?? 'Unassigned'; + String get detectedPaymentCard => + _localizedStrings['detectedPaymentCard'] ?? 'Card detected'; + String detectedPaymentCardDescription(String issuer, String last4) { + final template = _localizedStrings['detectedPaymentCardDescription'] ?? + '@ was detected from SMS.'; + final label = last4.isNotEmpty ? '$issuer · ****$last4' : issuer; + return template.replaceAll('@', label); + } + + String get addDetectedPaymentCard => + _localizedStrings['addDetectedPaymentCard'] ?? 'Add card'; + String get paymentCardUnassignedWarning => + _localizedStrings['paymentCardUnassignedWarning'] ?? + 'Without a card selection this subscription will be saved as "Unassigned".'; + String get addNewCard => _localizedStrings['addNewCard'] ?? 'Add New Card'; + String get managePaymentCards => + _localizedStrings['managePaymentCards'] ?? 'Manage Cards'; + String get choosePaymentCard => + _localizedStrings['choosePaymentCard'] ?? 'Choose Payment Card'; + String get analysisCardFilterLabel => + _localizedStrings['analysisCardFilterLabel'] ?? 'Filter by payment card'; + String get analysisCardFilterAll => + _localizedStrings['analysisCardFilterAll'] ?? 'All cards'; + String get cardDefaultBadge => + _localizedStrings['cardDefaultBadge'] ?? 'Default'; + String get noPaymentCards => + _localizedStrings['noPaymentCards'] ?? 'No payment cards saved yet.'; + String get areYouSure => _localizedStrings['areYouSure'] ?? 'Are you sure?'; // SMS 권한 온보딩/설정 String get smsPermissionTitle => _localizedStrings['smsPermissionTitle'] ?? 'Request SMS Permission'; diff --git a/lib/main.dart b/lib/main.dart index 4c428ff..b262854 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -5,10 +5,12 @@ import 'package:flutter/foundation.dart' show kIsWeb, kDebugMode; import 'package:flutter_localizations/flutter_localizations.dart'; import 'models/subscription_model.dart'; import 'models/category_model.dart'; +import 'models/payment_card_model.dart'; import 'providers/subscription_provider.dart'; import 'providers/app_lock_provider.dart'; import 'providers/notification_provider.dart'; import 'providers/navigation_provider.dart'; +import 'providers/payment_card_provider.dart'; import 'services/notification_service.dart'; import 'providers/category_provider.dart'; import 'providers/locale_provider.dart'; @@ -69,14 +71,17 @@ Future main() async { await Hive.initFlutter(); Hive.registerAdapter(SubscriptionModelAdapter()); Hive.registerAdapter(CategoryModelAdapter()); + Hive.registerAdapter(PaymentCardModelAdapter()); await Hive.openBox('subscriptions'); await Hive.openBox('categories'); + await Hive.openBox('payment_cards'); final appLockBox = await Hive.openBox('app_lock'); // 알림 서비스를 가장 먼저 초기화 await NotificationService.init(); final subscriptionProvider = SubscriptionProvider(); final categoryProvider = CategoryProvider(); + final paymentCardProvider = PaymentCardProvider(); final localeProvider = LocaleProvider(); final notificationProvider = NotificationProvider(); final themeProvider = ThemeProvider(); @@ -84,6 +89,7 @@ Future main() async { await subscriptionProvider.init(); await categoryProvider.init(); + await paymentCardProvider.init(); await localeProvider.init(); await notificationProvider.init(); await themeProvider.initialize(); @@ -110,6 +116,7 @@ Future main() async { providers: [ ChangeNotifierProvider(create: (_) => subscriptionProvider), ChangeNotifierProvider(create: (_) => categoryProvider), + ChangeNotifierProvider(create: (_) => paymentCardProvider), ChangeNotifierProvider(create: (_) => AppLockProvider(appLockBox)), ChangeNotifierProvider(create: (_) => notificationProvider), ChangeNotifierProvider(create: (_) => localeProvider), diff --git a/lib/models/payment_card_model.dart b/lib/models/payment_card_model.dart new file mode 100644 index 0000000..e3ce84b --- /dev/null +++ b/lib/models/payment_card_model.dart @@ -0,0 +1,33 @@ +import 'package:hive/hive.dart'; + +part 'payment_card_model.g.dart'; + +@HiveType(typeId: 2) +class PaymentCardModel extends HiveObject { + @HiveField(0) + String id; + + @HiveField(1) + String issuerName; + + @HiveField(2) + String last4; + + @HiveField(3) + String colorHex; + + @HiveField(4) + String iconName; + + @HiveField(5) + bool isDefault; + + PaymentCardModel({ + required this.id, + required this.issuerName, + required this.last4, + required this.colorHex, + required this.iconName, + this.isDefault = false, + }); +} diff --git a/lib/models/payment_card_model.g.dart b/lib/models/payment_card_model.g.dart new file mode 100644 index 0000000..7e7d981 --- /dev/null +++ b/lib/models/payment_card_model.g.dart @@ -0,0 +1,56 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'payment_card_model.dart'; + +// ************************************************************************** +// TypeAdapterGenerator +// ************************************************************************** + +class PaymentCardModelAdapter extends TypeAdapter { + @override + final int typeId = 2; + + @override + PaymentCardModel read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return PaymentCardModel( + id: fields[0] as String, + issuerName: fields[1] as String, + last4: fields[2] as String, + colorHex: fields[3] as String, + iconName: fields[4] as String, + isDefault: fields[5] as bool, + ); + } + + @override + void write(BinaryWriter writer, PaymentCardModel obj) { + writer + ..writeByte(6) + ..writeByte(0) + ..write(obj.id) + ..writeByte(1) + ..write(obj.issuerName) + ..writeByte(2) + ..write(obj.last4) + ..writeByte(3) + ..write(obj.colorHex) + ..writeByte(4) + ..write(obj.iconName) + ..writeByte(5) + ..write(obj.isDefault); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is PaymentCardModelAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} diff --git a/lib/models/payment_card_suggestion.dart b/lib/models/payment_card_suggestion.dart new file mode 100644 index 0000000..bb8ad54 --- /dev/null +++ b/lib/models/payment_card_suggestion.dart @@ -0,0 +1,14 @@ +/// SMS 스캔 등에서 추출한 결제수단 정보 제안 +class PaymentCardSuggestion { + final String issuerName; + final String? last4; + final String? source; // 예: SMS, OCR 등 + + const PaymentCardSuggestion({ + required this.issuerName, + this.last4, + this.source, + }); + + bool get hasLast4 => last4 != null && last4!.length == 4; +} diff --git a/lib/models/subscription.dart b/lib/models/subscription.dart index 6cc119b..4ac1021 100644 --- a/lib/models/subscription.dart +++ b/lib/models/subscription.dart @@ -1,3 +1,5 @@ +import 'payment_card_suggestion.dart'; + class Subscription { final String id; final String serviceName; @@ -10,6 +12,8 @@ class Subscription { final DateTime? lastPaymentDate; final String? websiteUrl; final String currency; + final String? paymentCardId; + final PaymentCardSuggestion? paymentCardSuggestion; Subscription({ required this.id, @@ -23,6 +27,8 @@ class Subscription { this.lastPaymentDate, this.websiteUrl, this.currency = 'KRW', + this.paymentCardId, + this.paymentCardSuggestion, }); Map toMap() { @@ -38,6 +44,10 @@ class Subscription { 'lastPaymentDate': lastPaymentDate?.toIso8601String(), 'websiteUrl': websiteUrl, 'currency': currency, + 'paymentCardId': paymentCardId, + 'paymentCardSuggestionIssuer': paymentCardSuggestion?.issuerName, + 'paymentCardSuggestionLast4': paymentCardSuggestion?.last4, + 'paymentCardSuggestionSource': paymentCardSuggestion?.source, }; } @@ -56,6 +66,14 @@ class Subscription { : null, websiteUrl: map['websiteUrl'] as String?, currency: map['currency'] as String? ?? 'KRW', + paymentCardId: map['paymentCardId'] as String?, + paymentCardSuggestion: map['paymentCardSuggestionIssuer'] != null + ? PaymentCardSuggestion( + issuerName: map['paymentCardSuggestionIssuer'] as String, + last4: map['paymentCardSuggestionLast4'] as String?, + source: map['paymentCardSuggestionSource'] as String?, + ) + : null, ); } diff --git a/lib/models/subscription_model.dart b/lib/models/subscription_model.dart index c5ec3d1..07bf118 100644 --- a/lib/models/subscription_model.dart +++ b/lib/models/subscription_model.dart @@ -49,6 +49,9 @@ class SubscriptionModel extends HiveObject { @HiveField(14) double? eventPrice; // 이벤트 기간 중 가격 + @HiveField(15) + String? paymentCardId; // 연결된 결제수단의 ID + SubscriptionModel({ required this.id, required this.serviceName, @@ -65,6 +68,7 @@ class SubscriptionModel extends HiveObject { this.eventStartDate, this.eventEndDate, this.eventPrice, + this.paymentCardId, }); // 주기적 결제 여부 확인 diff --git a/lib/models/subscription_model.g.dart b/lib/models/subscription_model.g.dart index 88a8bd9..4c99eb7 100644 --- a/lib/models/subscription_model.g.dart +++ b/lib/models/subscription_model.g.dart @@ -32,13 +32,14 @@ class SubscriptionModelAdapter extends TypeAdapter { eventStartDate: fields[12] as DateTime?, eventEndDate: fields[13] as DateTime?, eventPrice: fields[14] as double?, + paymentCardId: fields[15] as String?, ); } @override void write(BinaryWriter writer, SubscriptionModel obj) { writer - ..writeByte(15) + ..writeByte(16) ..writeByte(0) ..write(obj.id) ..writeByte(1) @@ -68,7 +69,9 @@ class SubscriptionModelAdapter extends TypeAdapter { ..writeByte(13) ..write(obj.eventEndDate) ..writeByte(14) - ..write(obj.eventPrice); + ..write(obj.eventPrice) + ..writeByte(15) + ..write(obj.paymentCardId); } @override diff --git a/lib/providers/payment_card_provider.dart b/lib/providers/payment_card_provider.dart new file mode 100644 index 0000000..4cfe09e --- /dev/null +++ b/lib/providers/payment_card_provider.dart @@ -0,0 +1,124 @@ +import 'package:flutter/material.dart'; +import 'package:hive/hive.dart'; +import 'package:uuid/uuid.dart'; + +import '../models/payment_card_model.dart'; + +class PaymentCardProvider extends ChangeNotifier { + late Box _cardBox; + final List _cards = []; + + List get cards => List.unmodifiable(_cards); + + PaymentCardModel? get defaultCard { + try { + return _cards.firstWhere((card) => card.isDefault); + } catch (_) { + return _cards.isNotEmpty ? _cards.first : null; + } + } + + Future init() async { + _cardBox = await Hive.openBox('payment_cards'); + _cards + ..clear() + ..addAll(_cardBox.values); + _sortCards(); + notifyListeners(); + } + + Future addCard({ + required String issuerName, + required String last4, + required String colorHex, + required String iconName, + bool isDefault = false, + }) async { + if (isDefault) { + await _unsetDefaultCard(); + } + + final card = PaymentCardModel( + id: const Uuid().v4(), + issuerName: issuerName, + last4: last4, + colorHex: colorHex, + iconName: iconName, + isDefault: isDefault, + ); + + await _cardBox.put(card.id, card); + _cards.add(card); + _sortCards(); + notifyListeners(); + return card; + } + + Future updateCard(PaymentCardModel updated) async { + final index = _cards.indexWhere((card) => card.id == updated.id); + if (index == -1) return; + + if (updated.isDefault) { + await _unsetDefaultCard(exceptId: updated.id); + } + + _cards[index] = updated; + await _cardBox.put(updated.id, updated); + _sortCards(); + notifyListeners(); + } + + Future deleteCard(String id) async { + await _cardBox.delete(id); + _cards.removeWhere((card) => card.id == id); + + if (!_cards.any((card) => card.isDefault) && _cards.isNotEmpty) { + _cards.first.isDefault = true; + await _cardBox.put(_cards.first.id, _cards.first); + } + + _sortCards(); + notifyListeners(); + } + + Future setDefaultCard(String id) async { + final index = _cards.indexWhere((card) => card.id == id); + if (index == -1) return; + + await _unsetDefaultCard(exceptId: id); + _cards[index].isDefault = true; + await _cardBox.put(id, _cards[index]); + _sortCards(); + notifyListeners(); + } + + PaymentCardModel? getCardById(String? id) { + if (id == null) return null; + try { + return _cards.firstWhere((card) => card.id == id); + } catch (_) { + return null; + } + } + + void _sortCards() { + _cards.sort((a, b) { + if (a.isDefault != b.isDefault) { + return a.isDefault ? -1 : 1; + } + final issuerCompare = + a.issuerName.toLowerCase().compareTo(b.issuerName.toLowerCase()); + if (issuerCompare != 0) return issuerCompare; + return a.last4.compareTo(b.last4); + }); + } + + Future _unsetDefaultCard({String? exceptId}) async { + for (final card in _cards) { + if (card.isDefault && card.id != exceptId) { + card.isDefault = false; + await _cardBox.put(card.id, card); + } + } + } +} diff --git a/lib/providers/subscription_provider.dart b/lib/providers/subscription_provider.dart index 2d0d24c..5edc9d2 100644 --- a/lib/providers/subscription_provider.dart +++ b/lib/providers/subscription_provider.dart @@ -118,6 +118,7 @@ class SubscriptionProvider extends ChangeNotifier { required DateTime nextBillingDate, String? websiteUrl, String? categoryId, + String? paymentCardId, bool isAutoDetected = false, int repeatCount = 1, DateTime? lastPaymentDate, @@ -136,6 +137,7 @@ class SubscriptionProvider extends ChangeNotifier { nextBillingDate: nextBillingDate, websiteUrl: websiteUrl, categoryId: categoryId, + paymentCardId: paymentCardId, isAutoDetected: isAutoDetected, repeatCount: repeatCount, lastPaymentDate: lastPaymentDate, @@ -268,17 +270,22 @@ class SubscriptionProvider extends ChangeNotifier { } /// 총 월간 지출을 계산합니다. (로케일별 기본 통화로 환산) - Future calculateTotalExpense({String? locale}) async { - if (_subscriptions.isEmpty) return 0.0; + Future calculateTotalExpense({ + String? locale, + List? subset, + }) async { + final targetSubscriptions = subset ?? _subscriptions; + if (targetSubscriptions.isEmpty) return 0.0; // locale이 제공되지 않으면 현재 로케일 사용 final targetCurrency = locale != null ? CurrencyUtil.getDefaultCurrency(locale) : 'KRW'; // 기본값 - debugPrint('[calculateTotalExpense] 계산 시작 - 타겟 통화: $targetCurrency'); + debugPrint('[calculateTotalExpense] 계산 시작 - 타겟 통화: $targetCurrency, ' + '대상 구독: ${targetSubscriptions.length}개'); double total = 0.0; - for (final subscription in _subscriptions) { + for (final subscription in targetSubscriptions) { final currentPrice = subscription.currentPrice; debugPrint('[calculateTotalExpense] ${subscription.serviceName}: ' '$currentPrice ${subscription.currency} (이벤트 적용: ${subscription.isCurrentlyInEvent})'); @@ -292,15 +299,19 @@ class SubscriptionProvider extends ChangeNotifier { total += converted ?? currentPrice; } - debugPrint('[calculateTotalExpense] 총 지출 계산 완료: $total $targetCurrency'); + debugPrint('[calculateTotalExpense] 총 지출 계산 완료: $total ' + '$targetCurrency (대상 ${targetSubscriptions.length}개)'); return total; } /// 최근 6개월의 월별 지출 데이터를 반환합니다. (로케일별 기본 통화로 환산) - Future>> getMonthlyExpenseData( - {String? locale}) async { + Future>> getMonthlyExpenseData({ + String? locale, + List? subset, + }) async { final now = DateTime.now(); final List> monthlyData = []; + final targetSubscriptions = subset ?? _subscriptions; // locale이 제공되지 않으면 현재 로케일 사용 final targetCurrency = @@ -321,7 +332,7 @@ class SubscriptionProvider extends ChangeNotifier { } // 해당 월에 활성화된 구독 계산 - for (final subscription in _subscriptions) { + for (final subscription in targetSubscriptions) { if (isCurrentMonth) { // 현재 월인 경우: 모든 활성 구독 포함 (calculateTotalExpense와 동일하게) final cost = subscription.currentPrice; diff --git a/lib/routes/app_routes.dart b/lib/routes/app_routes.dart index 882a24f..05ab846 100644 --- a/lib/routes/app_routes.dart +++ b/lib/routes/app_routes.dart @@ -8,6 +8,7 @@ import 'package:submanager/screens/settings_screen.dart'; import 'package:submanager/screens/splash_screen.dart'; import 'package:submanager/screens/sms_permission_screen.dart'; import 'package:submanager/models/subscription_model.dart'; +import 'package:submanager/screens/payment_card_management_screen.dart'; class AppRoutes { static const String splash = '/splash'; @@ -18,6 +19,7 @@ class AppRoutes { static const String analysis = '/analysis'; static const String settings = '/settings'; static const String smsPermission = '/sms-permission'; + static const String paymentCardManagement = '/payment-card-management'; static Map getRoutes() { return { @@ -28,6 +30,7 @@ class AppRoutes { analysis: (context) => const AnalysisScreen(), settings: (context) => const SettingsScreen(), smsPermission: (context) => const SmsPermissionScreen(), + paymentCardManagement: (context) => const PaymentCardManagementScreen(), }; } @@ -61,6 +64,8 @@ class AppRoutes { case smsPermission: return _buildRoute(const SmsPermissionScreen(), routeSettings); + case paymentCardManagement: + return _buildRoute(const PaymentCardManagementScreen(), routeSettings); default: return _errorRoute(); diff --git a/lib/screens/analysis_screen.dart b/lib/screens/analysis_screen.dart index 8d3ae18..2fa3468 100644 --- a/lib/screens/analysis_screen.dart +++ b/lib/screens/analysis_screen.dart @@ -1,7 +1,12 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import '../l10n/app_localizations.dart'; +import '../models/payment_card_model.dart'; +import '../models/subscription_model.dart'; +import '../providers/payment_card_provider.dart'; import '../providers/subscription_provider.dart'; import '../providers/locale_provider.dart'; +import '../utils/payment_card_utils.dart'; import '../widgets/native_ad_widget.dart'; import '../widgets/analysis/analysis_screen_spacer.dart'; import '../widgets/analysis/subscription_pie_chart_card.dart'; @@ -9,6 +14,8 @@ import '../widgets/analysis/total_expense_summary_card.dart'; import '../widgets/analysis/monthly_expense_chart_card.dart'; import '../widgets/analysis/event_analysis_card.dart'; +enum AnalysisCardFilterType { all, unassigned, card } + class AnalysisScreen extends StatefulWidget { const AnalysisScreen({super.key}); @@ -25,6 +32,8 @@ class _AnalysisScreenState extends State List> _monthlyData = []; bool _isLoading = true; String _lastDataHash = ''; + AnalysisCardFilterType _filterType = AnalysisCardFilterType.all; + String? _selectedCardId; @override void initState() { @@ -42,7 +51,8 @@ class _AnalysisScreenState extends State super.didChangeDependencies(); // Provider 변경 감지 final provider = Provider.of(context); - final currentHash = _calculateDataHash(provider); + final filtered = _filterSubscriptions(provider.subscriptions); + final currentHash = _calculateDataHash(provider, filtered: filtered); debugPrint('[AnalysisScreen] didChangeDependencies: ' '현재 해시=$currentHash, 이전 해시=$_lastDataHash, 로딩중=$_isLoading'); @@ -64,13 +74,16 @@ class _AnalysisScreenState extends State } /// 구독 데이터의 해시값을 계산하여 변경 감지 - String _calculateDataHash(SubscriptionProvider provider) { - final subscriptions = provider.subscriptions; - final buffer = StringBuffer(); - - buffer.write(subscriptions.length); - buffer.write('_'); - buffer.write(provider.totalMonthlyExpense.toStringAsFixed(2)); + String _calculateDataHash( + SubscriptionProvider provider, { + List? filtered, + }) { + final subscriptions = + filtered ?? _filterSubscriptions(provider.subscriptions); + final buffer = StringBuffer() + ..write(_filterType.name) + ..write('_${_selectedCardId ?? 'all'}') + ..write('_${subscriptions.length}'); for (final sub in subscriptions) { buffer.write( @@ -80,6 +93,38 @@ class _AnalysisScreenState extends State return buffer.toString(); } + List _filterSubscriptions( + List subscriptions) { + switch (_filterType) { + case AnalysisCardFilterType.all: + return subscriptions; + case AnalysisCardFilterType.unassigned: + return subscriptions.where((sub) => sub.paymentCardId == null).toList(); + case AnalysisCardFilterType.card: + final cardId = _selectedCardId; + if (cardId == null) return subscriptions; + return subscriptions + .where((sub) => sub.paymentCardId == cardId) + .toList(); + } + } + + Future _onFilterChanged(AnalysisCardFilterType type, + {String? cardId}) async { + if (_filterType == type) { + if (type != AnalysisCardFilterType.card || _selectedCardId == cardId) { + return; + } + } + + setState(() { + _filterType = type; + _selectedCardId = type == AnalysisCardFilterType.card ? cardId : null; + }); + + await _loadData(); + } + Future _loadData() async { debugPrint('[AnalysisScreen] _loadData 호출됨'); setState(() { @@ -89,17 +134,25 @@ class _AnalysisScreenState extends State final provider = Provider.of(context, listen: false); final localeProvider = Provider.of(context, listen: false); final locale = localeProvider.locale.languageCode; + final filteredSubscriptions = _filterSubscriptions(provider.subscriptions); // 총 지출 계산 (로케일별 기본 통화로 환산) - _totalExpense = await provider.calculateTotalExpense(locale: locale); + _totalExpense = await provider.calculateTotalExpense( + locale: locale, + subset: filteredSubscriptions, + ); debugPrint('[AnalysisScreen] 총 지출 계산 완료: $_totalExpense'); // 월별 데이터 계산 (로케일별 기본 통화로 환산) - _monthlyData = await provider.getMonthlyExpenseData(locale: locale); + _monthlyData = await provider.getMonthlyExpenseData( + locale: locale, + subset: filteredSubscriptions, + ); debugPrint('[AnalysisScreen] 월별 데이터 계산 완료: ${_monthlyData.length}개월'); // 현재 데이터 해시값 저장 - _lastDataHash = _calculateDataHash(provider); + _lastDataHash = + _calculateDataHash(provider, filtered: filteredSubscriptions); debugPrint('[AnalysisScreen] 데이터 해시값 저장: $_lastDataHash'); setState(() { @@ -130,6 +183,128 @@ class _AnalysisScreenState extends State ); } + Widget _buildCardFilterSection( + BuildContext context, PaymentCardProvider cardProvider) { + final loc = AppLocalizations.of(context); + final chips = [ + _buildGenericFilterChip( + context: context, + label: loc.analysisCardFilterAll, + icon: Icons.credit_card, + selected: _filterType == AnalysisCardFilterType.all, + onTap: () => _onFilterChanged(AnalysisCardFilterType.all), + ), + const SizedBox(width: 8), + _buildGenericFilterChip( + context: context, + label: loc.paymentCardUnassigned, + icon: Icons.credit_card_off_rounded, + selected: _filterType == AnalysisCardFilterType.unassigned, + onTap: () => _onFilterChanged(AnalysisCardFilterType.unassigned), + ), + ]; + + for (final card in cardProvider.cards) { + chips.add(const SizedBox(width: 8)); + chips.add(_buildPaymentCardChip(context, card)); + } + + return SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + loc.analysisCardFilterLabel, + style: Theme.of(context).textTheme.labelLarge?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 8), + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row(children: chips), + ), + ], + ), + ), + ); + } + + Widget _buildGenericFilterChip({ + required BuildContext context, + required String label, + required IconData icon, + required bool selected, + required VoidCallback onTap, + }) { + final cs = Theme.of(context).colorScheme; + return Semantics( + selected: selected, + button: true, + label: label, + child: ChoiceChip( + label: Text(label), + avatar: Icon( + icon, + size: 16, + color: selected ? cs.onPrimary : cs.onSurfaceVariant, + ), + selected: selected, + onSelected: (_) => onTap(), + selectedColor: cs.primary, + labelStyle: TextStyle( + color: selected ? cs.onPrimary : cs.onSurface, + fontWeight: FontWeight.w600, + ), + backgroundColor: cs.surface, + side: BorderSide( + color: + selected ? Colors.transparent : cs.outline.withValues(alpha: 0.5), + ), + ), + ); + } + + Widget _buildPaymentCardChip(BuildContext context, PaymentCardModel card) { + final color = PaymentCardUtils.colorFromHex(card.colorHex); + final icon = PaymentCardUtils.iconForName(card.iconName); + final cs = Theme.of(context).colorScheme; + final selected = _filterType == AnalysisCardFilterType.card && + _selectedCardId == card.id; + final labelText = '${card.issuerName} · ****${card.last4}'; + return Semantics( + label: labelText, + selected: selected, + button: true, + child: ChoiceChip( + avatar: CircleAvatar( + backgroundColor: + selected ? cs.onPrimary : color.withValues(alpha: 0.15), + child: Icon( + icon, + size: 16, + color: selected ? color : cs.onSurface, + ), + ), + label: Text(labelText), + selected: selected, + onSelected: (_) => + _onFilterChanged(AnalysisCardFilterType.card, cardId: card.id), + selectedColor: color, + backgroundColor: cs.surface, + labelStyle: TextStyle( + color: selected ? cs.onPrimary : cs.onSurface, + fontWeight: FontWeight.w600, + ), + side: BorderSide( + color: selected ? Colors.transparent : color.withValues(alpha: 0.5), + ), + ), + ); + } + @override Widget build(BuildContext context) { // Provider를 직접 사용하여 변경 감지 @@ -142,6 +317,9 @@ class _AnalysisScreenState extends State ); } + final cardProvider = Provider.of(context); + final filteredSubscriptions = _filterSubscriptions(subscriptions); + return CustomScrollView( controller: _scrollController, physics: const BouncingScrollPhysics(), @@ -159,9 +337,13 @@ class _AnalysisScreenState extends State const AnalysisScreenSpacer(), + _buildCardFilterSection(context, cardProvider), + + const AnalysisScreenSpacer(), + // 1. 구독 비율 파이 차트 SubscriptionPieChartCard( - subscriptions: subscriptions, + subscriptions: filteredSubscriptions, animationController: _animationController, ), @@ -170,7 +352,7 @@ class _AnalysisScreenState extends State // 2. 총 지출 요약 카드 TotalExpenseSummaryCard( key: ValueKey('total_expense_$_lastDataHash'), - subscriptions: subscriptions, + subscriptions: filteredSubscriptions, totalExpense: _totalExpense, animationController: _animationController, ), @@ -189,6 +371,7 @@ class _AnalysisScreenState extends State // 4. 이벤트 분석 EventAnalysisCard( animationController: _animationController, + subscriptions: filteredSubscriptions, ), // FloatingNavigationBar를 위한 충분한 하단 여백 diff --git a/lib/screens/detail_screen.dart b/lib/screens/detail_screen.dart index 71dc565..e025ef9 100644 --- a/lib/screens/detail_screen.dart +++ b/lib/screens/detail_screen.dart @@ -3,6 +3,7 @@ import 'package:provider/provider.dart'; import '../models/subscription_model.dart'; import '../controllers/detail_screen_controller.dart'; import '../widgets/detail/detail_header_section.dart'; +import '../widgets/detail/detail_payment_info_section.dart'; import '../widgets/detail/detail_form_section.dart'; import '../widgets/detail/detail_event_section.dart'; import '../widgets/detail/detail_url_section.dart'; @@ -120,6 +121,13 @@ class _DetailScreenState extends State ), const SizedBox(height: 16), + DetailPaymentInfoSection( + controller: _controller, + fadeAnimation: _controller.fadeAnimation!, + slideAnimation: _controller.slideAnimation!, + ), + const SizedBox(height: 16), + // 기본 정보 폼 섹션 DetailFormSection( controller: _controller, diff --git a/lib/screens/payment_card_management_screen.dart b/lib/screens/payment_card_management_screen.dart new file mode 100644 index 0000000..4a88e56 --- /dev/null +++ b/lib/screens/payment_card_management_screen.dart @@ -0,0 +1,144 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../l10n/app_localizations.dart'; +import '../models/payment_card_model.dart'; +import '../providers/payment_card_provider.dart'; +import '../utils/payment_card_utils.dart'; +import '../widgets/payment_card/payment_card_form_sheet.dart'; + +class PaymentCardManagementScreen extends StatelessWidget { + const PaymentCardManagementScreen({super.key}); + + Future _openForm(BuildContext context, {PaymentCardModel? card}) async { + await PaymentCardFormSheet.show(context, card: card); + } + + @override + Widget build(BuildContext context) { + final loc = AppLocalizations.of(context); + return Scaffold( + appBar: AppBar( + title: Text(loc.paymentCardManagement), + ), + body: Consumer( + builder: (context, provider, child) { + final cards = provider.cards; + if (cards.isEmpty) { + return Center( + child: Text( + loc.noPaymentCards, + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant), + ), + ); + } + + return ListView.separated( + padding: const EdgeInsets.symmetric(vertical: 16), + itemCount: cards.length, + separatorBuilder: (_, __) => const Divider(height: 1), + itemBuilder: (context, index) { + final card = cards[index]; + final color = PaymentCardUtils.colorFromHex(card.colorHex); + final icon = PaymentCardUtils.iconForName(card.iconName); + return ListTile( + leading: CircleAvatar( + backgroundColor: color.withValues(alpha: 0.15), + child: Icon(icon, color: color), + ), + title: Row( + children: [ + Expanded(child: Text(card.issuerName)), + if (card.isDefault) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Text( + loc.cardDefaultBadge, + style: TextStyle( + color: Theme.of(context) + .colorScheme + .onPrimaryContainer, + fontSize: 12, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + subtitle: Text('****${card.last4}'), + trailing: PopupMenuButton( + onSelected: (value) => + _handleMenuSelection(context, value, card, provider), + itemBuilder: (_) => [ + PopupMenuItem( + value: 'default', + child: Text(loc.setAsDefaultCard), + ), + PopupMenuItem( + value: 'edit', + child: Text(loc.editPaymentCard), + ), + PopupMenuItem( + value: 'delete', + child: Text(loc.delete), + ), + ], + ), + onTap: () => _openForm(context, card: card), + ); + }, + ); + }, + ), + floatingActionButton: FloatingActionButton.extended( + onPressed: () => _openForm(context), + icon: const Icon(Icons.add), + label: Text(loc.addPaymentCard), + ), + ); + } + + void _handleMenuSelection( + BuildContext context, + String value, + PaymentCardModel card, + PaymentCardProvider provider, + ) async { + switch (value) { + case 'default': + await provider.setDefaultCard(card.id); + break; + case 'edit': + await _openForm(context, card: card); + break; + case 'delete': + final confirmed = await showDialog( + context: context, + builder: (_) => AlertDialog( + title: Text(AppLocalizations.of(context).delete), + content: Text(AppLocalizations.of(context).areYouSure), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: Text(AppLocalizations.of(context).cancel), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(true), + child: Text(AppLocalizations.of(context).delete), + ), + ], + ), + ); + if (confirmed == true) { + await provider.deleteCard(card.id); + } + break; + } + } +} diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 4c5ff4b..c5ecf88 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -17,6 +17,7 @@ import '../providers/theme_provider.dart'; import '../theme/adaptive_theme.dart'; import '../widgets/common/layout/page_container.dart'; import '../theme/color_scheme_ext.dart'; +import '../widgets/app_navigator.dart'; class SettingsScreen extends StatelessWidget { const SettingsScreen({super.key}); @@ -79,6 +80,7 @@ class SettingsScreen extends StatelessWidget { @override Widget build(BuildContext context) { + final loc = AppLocalizations.of(context); return Column( children: [ Expanded( @@ -99,6 +101,48 @@ class SettingsScreen extends StatelessWidget { const SizedBox(height: 16), // 테마 모드 설정 + Card( + margin: + const EdgeInsets.symmetric(vertical: 8, horizontal: 16), + elevation: 1, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: BorderSide( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.5), + ), + ), + child: Semantics( + button: true, + label: loc.paymentCardManagement, + hint: loc.paymentCardManagementDescription, + child: ListTile( + leading: Icon( + Icons.credit_card, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + title: Text( + loc.paymentCardManagement, + style: TextStyle( + color: Theme.of(context).colorScheme.onSurface, + ), + ), + subtitle: Text( + loc.paymentCardManagementDescription, + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + trailing: Icon(Icons.chevron_right_rounded, + color: + Theme.of(context).colorScheme.onSurfaceVariant), + onTap: () => + AppNavigator.toPaymentCardManagement(context), + ), + ), + ), Card( margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), diff --git a/lib/screens/sms_scan_screen.dart b/lib/screens/sms_scan_screen.dart index d766ee3..23a9390 100644 --- a/lib/screens/sms_scan_screen.dart +++ b/lib/screens/sms_scan_screen.dart @@ -6,6 +6,9 @@ import '../widgets/sms_scan/scan_progress_widget.dart'; import '../widgets/sms_scan/subscription_card_widget.dart'; import '../widgets/common/snackbar/app_snackbar.dart'; import '../l10n/app_localizations.dart'; +import '../widgets/payment_card/payment_card_form_sheet.dart'; +import '../routes/app_routes.dart'; +import '../models/payment_card_suggestion.dart'; class SmsScanScreen extends StatefulWidget { const SmsScanScreen({super.key}); @@ -96,8 +99,23 @@ class _SmsScanScreenState extends State { websiteUrlController: _controller.websiteUrlController, selectedCategoryId: _controller.selectedCategoryId, onCategoryChanged: _controller.setSelectedCategoryId, + selectedPaymentCardId: _controller.selectedPaymentCardId, + onPaymentCardChanged: _controller.setSelectedPaymentCardId, + onAddCard: () async { + final newCardId = await PaymentCardFormSheet.show(context); + if (newCardId != null) { + _controller.setSelectedPaymentCardId(newCardId); + } + }, + onManageCards: () { + Navigator.of(context).pushNamed(AppRoutes.paymentCardManagement); + }, onAdd: _handleAddSubscription, onSkip: _handleSkipSubscription, + detectedCardSuggestion: _controller.currentSuggestion, + showDetectedCardShortcut: _controller.shouldSuggestCardCreation, + onAddDetectedCard: (suggestion) => + _handleDetectedCardCreation(suggestion), ), ], ); @@ -114,6 +132,18 @@ class _SmsScanScreenState extends State { WidgetsBinding.instance.addPostFrameCallback((_) => _scrollToTop()); } + Future _handleDetectedCardCreation( + PaymentCardSuggestion suggestion) async { + final newCardId = await PaymentCardFormSheet.show( + context, + initialIssuerName: suggestion.issuerName, + initialLast4: suggestion.last4, + ); + if (newCardId != null) { + _controller.setSelectedPaymentCardId(newCardId); + } + } + void _scrollToTop() { if (!_scrollController.hasClients) return; _scrollController.animateTo( diff --git a/lib/services/sms_scan/sms_scan_result.dart b/lib/services/sms_scan/sms_scan_result.dart new file mode 100644 index 0000000..ca43fa7 --- /dev/null +++ b/lib/services/sms_scan/sms_scan_result.dart @@ -0,0 +1,14 @@ +import '../../models/subscription_model.dart'; +import '../../models/payment_card_suggestion.dart'; + +class SmsScanResult { + final SubscriptionModel model; + final PaymentCardSuggestion? cardSuggestion; + final String? rawMessage; + + SmsScanResult({ + required this.model, + this.cardSuggestion, + this.rawMessage, + }); +} diff --git a/lib/services/sms_scan/subscription_converter.dart b/lib/services/sms_scan/subscription_converter.dart index 153045a..0797f64 100644 --- a/lib/services/sms_scan/subscription_converter.dart +++ b/lib/services/sms_scan/subscription_converter.dart @@ -1,21 +1,21 @@ import '../../models/subscription.dart'; -import '../../models/subscription_model.dart'; +import 'sms_scan_result.dart'; class SubscriptionConverter { // SubscriptionModel 리스트를 Subscription 리스트로 변환 - List convertModelsToSubscriptions( - List models) { + List convertResultsToSubscriptions( + List results) { final result = []; - for (var model in models) { + for (final smsResult in results) { try { - final subscription = _convertSingle(model); + final subscription = _convertSingle(smsResult); result.add(subscription); // 개발 편의를 위한 디버그 로그 // ignore: avoid_print print( - '모델 변환 성공: ${model.serviceName}, 카테고리ID: ${model.categoryId}, URL: ${model.websiteUrl}, 통화: ${model.currency}'); + '모델 변환 성공: ${smsResult.model.serviceName}, 카테고리ID: ${smsResult.model.categoryId}, URL: ${smsResult.model.websiteUrl}, 통화: ${smsResult.model.currency}'); } catch (e) { // ignore: avoid_print print('모델 변환 중 오류 발생: $e'); @@ -26,7 +26,8 @@ class SubscriptionConverter { } // 단일 모델 변환 - Subscription _convertSingle(SubscriptionModel model) { + Subscription _convertSingle(SmsScanResult result) { + final model = result.model; return Subscription( id: model.id, serviceName: model.serviceName, @@ -38,6 +39,8 @@ class SubscriptionConverter { lastPaymentDate: model.lastPaymentDate, websiteUrl: model.websiteUrl, currency: model.currency, + paymentCardId: model.paymentCardId, + paymentCardSuggestion: result.cardSuggestion, ); } diff --git a/lib/services/sms_scanner.dart b/lib/services/sms_scanner.dart index 41362d3..efd693d 100644 --- a/lib/services/sms_scanner.dart +++ b/lib/services/sms_scanner.dart @@ -8,11 +8,13 @@ import '../temp/test_sms_data.dart'; import '../services/subscription_url_matcher.dart'; import '../utils/platform_helper.dart'; import '../utils/business_day_util.dart'; +import '../services/sms_scan/sms_scan_result.dart'; +import '../models/payment_card_suggestion.dart'; class SmsScanner { final SmsQuery _query = SmsQuery(); - Future> scanForSubscriptions() async { + Future> scanForSubscriptions() async { try { List smsList; Log.d('SmsScanner: 스캔 시작'); @@ -39,7 +41,7 @@ class SmsScanner { } // SMS 데이터를 분석하여 반복 결제되는 구독 식별 - final List subscriptions = []; + final List subscriptions = []; final serviceGroups = _groupMessagesByIdentifier(smsList); Log.d('SmsScanner: 그룹화된 키 수: ${serviceGroups.length}'); @@ -52,12 +54,12 @@ class SmsScanner { continue; } - final subscription = + final result = _parseSms(repeatResult.baseMessage, repeatResult.repeatCount); - if (subscription != null) { + if (result != null) { Log.i( - 'SmsScanner: 구독 추가: ${subscription.serviceName}, 반복 횟수: ${subscription.repeatCount}'); - subscriptions.add(subscription); + 'SmsScanner: 구독 추가: ${result.model.serviceName}, 반복 횟수: ${result.model.repeatCount}'); + subscriptions.add(result); } else { Log.w('SmsScanner: 구독 파싱 실패: ${entry.key}'); } @@ -99,7 +101,7 @@ class SmsScanner { // (사용되지 않음) 기존 단일 메시지 파서 제거됨 - Isolate 배치 파싱으로 대체 - SubscriptionModel? _parseSms(Map sms, int repeatCount) { + SmsScanResult? _parseSms(Map sms, int repeatCount) { try { final serviceName = sms['serviceName'] as String? ?? '알 수 없는 서비스'; final monthlyCost = (sms['monthlyCost'] as num?)?.toDouble() ?? 0.0; @@ -150,7 +152,7 @@ class SmsScanner { adjustedNextBillingDate = BusinessDayUtil.nextBusinessDay(adjustedNextBillingDate); - return SubscriptionModel( + final model = SubscriptionModel( id: DateTime.now().millisecondsSinceEpoch.toString(), serviceName: serviceName, monthlyCost: monthlyCost, @@ -162,11 +164,84 @@ class SmsScanner { websiteUrl: _extractWebsiteUrl(serviceName), currency: currency, // 통화 단위 설정 ); + + final suggestion = _extractPaymentCardSuggestion(message); + return SmsScanResult( + model: model, + cardSuggestion: suggestion, + rawMessage: message, + ); } catch (e) { return null; } } + PaymentCardSuggestion? _extractPaymentCardSuggestion(String message) { + if (message.isEmpty) return null; + final issuer = _detectCardIssuer(message); + final last4 = _detectCardLast4(message); + if (issuer == null && last4 == null) { + return null; + } + return PaymentCardSuggestion( + issuerName: issuer ?? '결제수단', + last4: last4, + source: 'sms', + ); + } + + String? _detectCardIssuer(String message) { + final normalized = message.toLowerCase(); + const issuerKeywords = { + 'KB국민카드': ['kb국민', '국민카드', 'kb card', 'kookmin'], + '신한카드': ['신한', 'shinhan'], + '우리카드': ['우리카드', 'woori'], + '하나카드': ['하나카드', 'hana card', 'hana'], + '농협카드': ['농협', 'nh', '농협카드'], + 'BC카드': ['bc카드', 'bc card'], + '삼성카드': ['삼성카드', 'samsung card'], + '롯데카드': ['롯데카드', 'lotte card'], + '현대카드': ['현대카드', 'hyundai card'], + '씨티카드': ['씨티카드', 'citi card', 'citibank'], + '카카오뱅크': ['카카오뱅크', 'kakaobank'], + '토스뱅크': ['토스뱅크', 'toss bank'], + 'Visa': ['visa'], + 'Mastercard': ['mastercard', 'master card'], + 'American Express': ['amex', 'american express'], + }; + + for (final entry in issuerKeywords.entries) { + final match = entry.value.any((keyword) => normalized.contains(keyword)); + if (match) { + return entry.key; + } + } + + return null; + } + + String? _detectCardLast4(String message) { + final patterns = [ + RegExp(r'\*{3,}\s*(\d{4})'), + RegExp(r'끝번호\s*(\d{4})'), + RegExp(r'마지막\s*(\d{4})'), + RegExp(r'\((\d{4})\)'), + RegExp(r'ending(?: in)?\s*(\d{4})', caseSensitive: false), + ]; + + for (final pattern in patterns) { + final match = pattern.firstMatch(message); + if (match != null && match.groupCount >= 1) { + final candidate = match.group(1); + if (candidate != null && candidate.length == 4) { + return candidate; + } + } + } + + return null; + } + // 다음 결제일 계산 (현재 날짜 기준으로 조정) DateTime _calculateNextBillingDate( DateTime billingDate, String billingCycle) { diff --git a/lib/utils/payment_card_utils.dart b/lib/utils/payment_card_utils.dart new file mode 100644 index 0000000..b0db48a --- /dev/null +++ b/lib/utils/payment_card_utils.dart @@ -0,0 +1,41 @@ +import 'package:flutter/material.dart'; + +/// 결제수단 관련 공통 유틸리티 +class PaymentCardUtils { + static const List colorPalette = [ + '#FF6B6B', + '#F97316', + '#F59E0B', + '#10B981', + '#06B6D4', + '#3B82F6', + '#6366F1', + '#8B5CF6', + '#EC4899', + '#14B8A6', + '#0EA5E9', + '#94A3B8', + ]; + + static const Map iconMap = { + 'credit_card': Icons.credit_card_rounded, + 'payments': Icons.payments_rounded, + 'wallet': Icons.account_balance_wallet_rounded, + 'bank': Icons.account_balance_rounded, + 'shopping': Icons.shopping_bag_rounded, + 'subscriptions': Icons.subscriptions_rounded, + 'bolt': Icons.bolt_rounded, + }; + + static IconData iconForName(String name) { + return iconMap[name] ?? Icons.credit_card_rounded; + } + + static Color colorFromHex(String hex) { + var value = hex.replaceAll('#', ''); + if (value.length == 6) { + value = 'ff$value'; + } + return Color(int.parse(value, radix: 16)); + } +} diff --git a/lib/utils/subscription_grouping_helper.dart b/lib/utils/subscription_grouping_helper.dart new file mode 100644 index 0000000..928fad1 --- /dev/null +++ b/lib/utils/subscription_grouping_helper.dart @@ -0,0 +1,155 @@ +import 'package:flutter/material.dart'; + +import '../l10n/app_localizations.dart'; +import '../models/payment_card_model.dart'; +import '../models/subscription_model.dart'; +import '../providers/category_provider.dart'; +import '../providers/payment_card_provider.dart'; +import 'subscription_category_helper.dart'; + +enum SubscriptionGroupingMode { category, paymentCard } + +class SubscriptionGroupData { + final String id; + final String title; + final List subscriptions; + final SubscriptionGroupingMode mode; + final PaymentCardModel? paymentCard; + final bool isUnassignedCard; + final String? categoryKey; + final String? subtitle; + + const SubscriptionGroupData({ + required this.id, + required this.title, + required this.subscriptions, + required this.mode, + this.paymentCard, + this.isUnassignedCard = false, + this.categoryKey, + this.subtitle, + }); +} + +class SubscriptionGroupingHelper { + static const _unassignedCardKey = '__unassigned__'; + + static List buildGroups({ + required BuildContext context, + required List subscriptions, + required SubscriptionGroupingMode mode, + required CategoryProvider categoryProvider, + required PaymentCardProvider paymentCardProvider, + }) { + if (mode == SubscriptionGroupingMode.paymentCard) { + return _groupByPaymentCard( + context: context, + subscriptions: subscriptions, + paymentCardProvider: paymentCardProvider, + ); + } + + return _groupByCategory( + context: context, + subscriptions: subscriptions, + categoryProvider: categoryProvider, + ); + } + + static List _groupByCategory({ + required BuildContext context, + required List subscriptions, + required CategoryProvider categoryProvider, + }) { + final localizedMap = SubscriptionCategoryHelper.categorizeSubscriptions( + subscriptions, categoryProvider, context); + + final orderMap = {}; + for (var i = 0; i < categoryProvider.categories.length; i++) { + orderMap[categoryProvider.categories[i].name] = i; + } + + final groups = localizedMap.entries.map((entry) { + final title = + categoryProvider.getLocalizedCategoryName(context, entry.key); + return SubscriptionGroupData( + id: entry.key, + title: title, + subscriptions: entry.value, + mode: SubscriptionGroupingMode.category, + categoryKey: entry.key, + ); + }).toList(); + + groups.sort((a, b) { + final ai = orderMap[a.categoryKey] ?? 999; + final bi = orderMap[b.categoryKey] ?? 999; + if (ai != bi) { + return ai.compareTo(bi); + } + return a.title.compareTo(b.title); + }); + + return groups; + } + + static List _groupByPaymentCard({ + required BuildContext context, + required List subscriptions, + required PaymentCardProvider paymentCardProvider, + }) { + final map = >{}; + + for (final sub in subscriptions) { + final key = sub.paymentCardId ?? _unassignedCardKey; + map.putIfAbsent(key, () => []).add(sub); + } + + final loc = AppLocalizations.of(context); + final groups = []; + + map.forEach((key, subs) { + if (key == _unassignedCardKey) { + groups.add( + SubscriptionGroupData( + id: key, + title: loc.paymentCardUnassigned, + subtitle: loc.paymentCardUnassigned, + subscriptions: subs, + mode: SubscriptionGroupingMode.paymentCard, + isUnassignedCard: true, + ), + ); + } else { + final card = paymentCardProvider.getCardById(key); + final title = card?.issuerName ?? loc.paymentCardUnassigned; + final subtitle = + card != null ? '****${card.last4}' : loc.paymentCardUnassigned; + groups.add( + SubscriptionGroupData( + id: key, + title: title, + subtitle: subtitle, + subscriptions: subs, + mode: SubscriptionGroupingMode.paymentCard, + paymentCard: card, + ), + ); + } + }); + + groups.sort((a, b) { + if (a.isUnassignedCard != b.isUnassignedCard) { + return a.isUnassignedCard ? 1 : -1; + } + final aDefault = a.paymentCard?.isDefault ?? false; + final bDefault = b.paymentCard?.isDefault ?? false; + if (aDefault != bDefault) { + return aDefault ? -1 : 1; + } + return a.title.toLowerCase().compareTo(b.title.toLowerCase()); + }); + + return groups; + } +} diff --git a/lib/widgets/add_subscription/add_subscription_form.dart b/lib/widgets/add_subscription/add_subscription_form.dart index 0d9e728..93e3354 100644 --- a/lib/widgets/add_subscription/add_subscription_form.dart +++ b/lib/widgets/add_subscription/add_subscription_form.dart @@ -10,6 +10,9 @@ import '../common/form_fields/date_picker_field.dart'; import '../common/form_fields/currency_dropdown_field.dart'; import '../common/form_fields/billing_cycle_selector.dart'; import '../common/form_fields/category_selector.dart'; +import '../payment_card/payment_card_selector.dart'; +import '../payment_card/payment_card_form_sheet.dart'; +import '../../routes/app_routes.dart'; // Glass 제거: Material 3 Card 사용 // Material colors only @@ -234,6 +237,35 @@ class AddSubscriptionForm extends StatelessWidget { ); }, ), + const SizedBox(height: 20), + Text( + AppLocalizations.of(context).paymentCard, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + PaymentCardSelector( + selectedCardId: controller.selectedPaymentCardId, + onChanged: (cardId) { + setState(() { + controller.selectedPaymentCardId = cardId; + }); + }, + onAddCard: () async { + final newCardId = await PaymentCardFormSheet.show(context); + if (newCardId != null) { + setState(() { + controller.selectedPaymentCardId = newCardId; + }); + } + }, + onManageCards: () { + Navigator.of(context) + .pushNamed(AppRoutes.paymentCardManagement); + }, + ), ], ), ), diff --git a/lib/widgets/analysis/event_analysis_card.dart b/lib/widgets/analysis/event_analysis_card.dart index a595262..887ff38 100644 --- a/lib/widgets/analysis/event_analysis_card.dart +++ b/lib/widgets/analysis/event_analysis_card.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; -import 'package:provider/provider.dart'; -import '../../providers/subscription_provider.dart'; + +import '../../models/subscription_model.dart'; import '../../services/currency_util.dart'; // Glass 제거: Material 3 Card 사용 import '../themed_text.dart'; @@ -11,303 +11,283 @@ import '../../theme/color_scheme_ext.dart'; /// 이벤트 할인 현황을 보여주는 카드 위젯 class EventAnalysisCard extends StatelessWidget { final AnimationController animationController; + final List subscriptions; const EventAnalysisCard({ super.key, required this.animationController, + required this.subscriptions, }); @override Widget build(BuildContext context) { + final activeEventSubscriptions = + subscriptions.where((sub) => sub.isCurrentlyInEvent).toList(); + if (activeEventSubscriptions.isEmpty) { + return const SliverToBoxAdapter(child: SizedBox.shrink()); + } + + final totalSavings = activeEventSubscriptions.fold( + 0, + (sum, sub) => sum + sub.eventSavings, + ); + return SliverToBoxAdapter( - child: Consumer( - builder: (context, provider, child) { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: provider.activeEventSubscriptions.isNotEmpty - ? FadeTransition( - opacity: CurvedAnimation( - parent: animationController, - curve: const Interval(0.6, 1.0, curve: Curves.easeOut), - ), - child: SlideTransition( - position: Tween( - begin: const Offset(0, 0.2), - end: Offset.zero, - ).animate(CurvedAnimation( - parent: animationController, - curve: const Interval(0.6, 1.0, curve: Curves.easeOut), - )), - child: Card( - elevation: 3, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - side: BorderSide( - color: Theme.of(context) - .colorScheme - .outline - .withValues(alpha: 0.5), - ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: FadeTransition( + opacity: CurvedAnimation( + parent: animationController, + curve: const Interval(0.6, 1.0, curve: Curves.easeOut), + ), + child: SlideTransition( + position: Tween( + begin: const Offset(0, 0.2), + end: Offset.zero, + ).animate( + CurvedAnimation( + parent: animationController, + curve: const Interval(0.6, 1.0, curve: Curves.easeOut), + ), + ), + child: Card( + elevation: 3, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: BorderSide( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.5), + ), + ), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + ThemedText.headline( + text: + AppLocalizations.of(context).eventDiscountStatus, + style: const TextStyle(fontSize: 18), ), - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.error, + borderRadius: BorderRadius.circular(4), + ), + child: Row( children: [ - Row( - mainAxisAlignment: - MainAxisAlignment.spaceBetween, - children: [ - ThemedText.headline( - text: AppLocalizations.of(context) - .eventDiscountStatus, - style: const TextStyle( - fontSize: 18, - ), - ), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 4, - ), - decoration: BoxDecoration( - color: - Theme.of(context).colorScheme.error, - borderRadius: BorderRadius.circular(4), - ), - child: Row( - children: [ - FaIcon( - FontAwesomeIcons.fire, - size: 12, - color: Theme.of(context) - .colorScheme - .onError, - ), - const SizedBox(width: 4), - Text( - AppLocalizations.of(context) - .servicesInProgress(provider - .activeEventSubscriptions - .length), - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.bold, - color: Theme.of(context) - .colorScheme - .onError, - ), - ), - ], - ), - ), - ], + FaIcon( + FontAwesomeIcons.fire, + size: 12, + color: Theme.of(context).colorScheme.onError, ), - const SizedBox(height: 16), - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Theme.of(context) - .colorScheme - .error - .withValues(alpha: 0.08), - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: Theme.of(context) - .colorScheme - .error - .withValues(alpha: 0.3), - ), - ), - child: Row( - children: [ - Icon( - Icons.savings, - color: - Theme.of(context).colorScheme.error, - size: 32, - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - ThemedText( - AppLocalizations.of(context) - .monthlySavingAmount, - style: const TextStyle( - fontSize: 12, - fontWeight: FontWeight.w500, - ), - ), - const SizedBox(height: 4), - ThemedText( - CurrencyUtil.formatTotalAmount( - provider.calculateTotalSavings(), - ), - style: TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, - color: Theme.of(context) - .colorScheme - .error, - ), - ), - ], - ), - ), - ], - ), - ), - const SizedBox(height: 16), - ThemedText( - AppLocalizations.of(context).eventsInProgress, - style: const TextStyle( - fontSize: 14, + const SizedBox(width: 4), + Text( + AppLocalizations.of(context).servicesInProgress( + activeEventSubscriptions.length), + style: TextStyle( + fontSize: 12, fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.onError, ), ), - const SizedBox(height: 12), - ...provider.activeEventSubscriptions.map((sub) { - final savings = sub.originalPrice - - (sub.eventPrice ?? sub.originalPrice); - final discountRate = - ((savings / sub.originalPrice) * 100) - .round(); - return Container( - margin: const EdgeInsets.only(bottom: 8), - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Theme.of(context) - .colorScheme - .onSurface - .withValues(alpha: 0.05), - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: Theme.of(context) - .colorScheme - .onSurfaceVariant - .withValues(alpha: 0.2), - ), - ), - child: Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - ThemedText( - sub.serviceName, - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 4), - Row( - children: [ - FutureBuilder( - future: - CurrencyUtil.formatAmount( - sub.originalPrice, - sub.currency), - builder: (context, snapshot) { - if (snapshot.hasData) { - return ThemedText( - snapshot.data!, - style: TextStyle( - fontSize: 12, - decoration: - TextDecoration - .lineThrough, - color: Theme.of( - context) - .colorScheme - .onSurfaceVariant, - ), - ); - } - return const SizedBox(); - }, - ), - const SizedBox(width: 8), - Icon( - Icons.arrow_forward, - size: 12, - color: Theme.of(context) - .colorScheme - .onSurfaceVariant, - ), - const SizedBox(width: 8), - FutureBuilder( - future: - CurrencyUtil.formatAmount( - sub.eventPrice ?? - sub.originalPrice, - sub.currency), - builder: (context, snapshot) { - if (snapshot.hasData) { - return ThemedText( - snapshot.data!, - style: TextStyle( - fontSize: 12, - fontWeight: - FontWeight.bold, - color: - Theme.of(context) - .colorScheme - .success, - ), - ); - } - return const SizedBox(); - }, - ), - ], - ), - ], - ), - ), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 4, - ), - decoration: BoxDecoration( - color: Theme.of(context) - .colorScheme - .error - .withValues(alpha: 0.2), - borderRadius: - BorderRadius.circular(4), - ), - child: Text( - _formatDiscountPercent( - context, discountRate), - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.bold, - color: Theme.of(context) - .colorScheme - .error, - ), - ), - ), - ], - ), - ); - }), ], ), ), + ], + ), + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Theme.of(context) + .colorScheme + .error + .withValues(alpha: 0.05), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: Theme.of(context) + .colorScheme + .error + .withValues(alpha: 0.2), + ), + ), + child: Row( + children: [ + Icon( + Icons.savings, + color: Theme.of(context).colorScheme.error, + size: 32, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ThemedText( + AppLocalizations.of(context) + .monthlySavingAmount, + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 4), + ThemedText( + CurrencyUtil.formatTotalAmount(totalSavings), + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.error, + ), + ), + ], + ), + ), + ], ), ), - ) - : const SizedBox.shrink(), - ); - }, + const SizedBox(height: 16), + ThemedText( + AppLocalizations.of(context).eventsInProgress, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 12), + ...activeEventSubscriptions.map((sub) { + final savings = sub.originalPrice - + (sub.eventPrice ?? sub.originalPrice); + final discountRate = + ((savings / sub.originalPrice) * 100).round(); + return Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Theme.of(context) + .colorScheme + .onSurface + .withValues(alpha: 0.05), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Theme.of(context) + .colorScheme + .onSurfaceVariant + .withValues(alpha: 0.2), + ), + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ThemedText( + sub.serviceName, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 4), + Row( + children: [ + FutureBuilder( + future: CurrencyUtil.formatAmount( + sub.originalPrice, sub.currency), + builder: (context, snapshot) { + if (snapshot.hasData) { + return ThemedText( + snapshot.data!, + style: TextStyle( + fontSize: 12, + decoration: + TextDecoration.lineThrough, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, + ), + ); + } + return const SizedBox(); + }, + ), + const SizedBox(width: 8), + Icon( + Icons.arrow_forward, + size: 12, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, + ), + const SizedBox(width: 8), + FutureBuilder( + future: CurrencyUtil.formatAmount( + sub.eventPrice ?? sub.originalPrice, + sub.currency, + ), + builder: (context, snapshot) { + if (snapshot.hasData) { + return ThemedText( + snapshot.data!, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: Theme.of(context) + .colorScheme + .success, + ), + ); + } + return const SizedBox(); + }, + ), + ], + ), + ], + ), + ), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: BoxDecoration( + color: Theme.of(context) + .colorScheme + .error + .withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + _formatDiscountPercent(context, discountRate), + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.error, + ), + ), + ), + ], + ), + ); + }), + ], + ), + ), + ), + ), + ), ), ); } diff --git a/lib/widgets/app_navigator.dart b/lib/widgets/app_navigator.dart index 9ea1917..c84734b 100644 --- a/lib/widgets/app_navigator.dart +++ b/lib/widgets/app_navigator.dart @@ -77,6 +77,12 @@ class AppNavigator { await Navigator.of(context).pushNamed(AppRoutes.settings); } + /// 결제수단 관리 화면으로 네비게이션 + static Future toPaymentCardManagement(BuildContext context) async { + HapticFeedback.lightImpact(); + await Navigator.of(context).pushNamed(AppRoutes.paymentCardManagement); + } + /// 카테고리 관리 화면으로 네비게이션 static Future toCategoryManagement(BuildContext context) async { HapticFeedback.lightImpact(); diff --git a/lib/widgets/category_header_widget.dart b/lib/widgets/category_header_widget.dart deleted file mode 100644 index 97c5ee6..0000000 --- a/lib/widgets/category_header_widget.dart +++ /dev/null @@ -1,126 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:intl/intl.dart'; -import '../l10n/app_localizations.dart'; - -/// 카테고리별 구독 그룹의 헤더 위젯 -/// -/// 카테고리 이름, 구독 개수, 총 비용을 표시합니다. -/// 통화별로 구분하여 표시하며, 혼재된 경우 각각 표시합니다. -class CategoryHeaderWidget extends StatelessWidget { - final String categoryName; - final int subscriptionCount; - final double totalCostUSD; - final double totalCostKRW; - final double totalCostJPY; - final double totalCostCNY; - - const CategoryHeaderWidget({ - super.key, - required this.categoryName, - required this.subscriptionCount, - required this.totalCostUSD, - required this.totalCostKRW, - required this.totalCostJPY, - required this.totalCostCNY, - }); - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.fromLTRB(20, 16, 20, 4), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - categoryName, - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w700, - color: Theme.of(context).colorScheme.onSurface, - ), - ), - Text( - _buildCostDisplay(context), - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w500, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ], - ), - const SizedBox(height: 8), - Divider( - height: 1, - thickness: 1, - color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3), - ), - ], - ), - ); - } - - /// 통화별 합계를 표시하는 문자열을 생성합니다. - String _buildCostDisplay(BuildContext context) { - final parts = []; - - // 개수는 항상 표시 - parts - .add(AppLocalizations.of(context).subscriptionCount(subscriptionCount)); - - // 통화 부분을 별도로 처리 - final currencyParts = []; - - // 달러가 있는 경우 - if (totalCostUSD > 0) { - final formatter = NumberFormat.currency( - locale: 'en_US', - symbol: '\$', - decimalDigits: 2, - ); - currencyParts.add(formatter.format(totalCostUSD)); - } - - // 원화가 있는 경우 - if (totalCostKRW > 0) { - final formatter = NumberFormat.currency( - locale: 'ko_KR', - symbol: '₩', - decimalDigits: 0, - ); - currencyParts.add(formatter.format(totalCostKRW)); - } - - // 엔화가 있는 경우 - if (totalCostJPY > 0) { - final formatter = NumberFormat.currency( - locale: 'ja_JP', - symbol: '¥', - decimalDigits: 0, - ); - currencyParts.add(formatter.format(totalCostJPY)); - } - - // 위안화가 있는 경우 - if (totalCostCNY > 0) { - final formatter = NumberFormat.currency( - locale: 'zh_CN', - symbol: '¥', - decimalDigits: 2, - ); - currencyParts.add(formatter.format(totalCostCNY)); - } - - // 통화가 하나 이상 있는 경우 - if (currencyParts.isNotEmpty) { - // 통화가 여러 개인 경우 + 로 연결, 하나인 경우 그대로 - final currencyDisplay = currencyParts.join(' + '); - parts.add(currencyDisplay); - } - - return parts.join(' · '); - } -} diff --git a/lib/widgets/detail/detail_form_section.dart b/lib/widgets/detail/detail_form_section.dart index fe902e6..73e0b3e 100644 --- a/lib/widgets/detail/detail_form_section.dart +++ b/lib/widgets/detail/detail_form_section.dart @@ -9,6 +9,9 @@ import '../common/form_fields/date_picker_field.dart'; import '../common/form_fields/currency_dropdown_field.dart'; import '../common/form_fields/billing_cycle_selector.dart'; import '../common/form_fields/category_selector.dart'; +import '../payment_card/payment_card_selector.dart'; +import '../payment_card/payment_card_form_sheet.dart'; +import '../../routes/app_routes.dart'; import '../../l10n/app_localizations.dart'; /// 상세 화면 폼 섹션 @@ -184,6 +187,33 @@ class DetailFormSection extends StatelessWidget { ); }, ), + const SizedBox(height: 20), + Text( + AppLocalizations.of(context).paymentCard, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurface, + ), + ), + const SizedBox(height: 8), + PaymentCardSelector( + selectedCardId: controller.selectedPaymentCardId, + onChanged: (cardId) { + controller.selectedPaymentCardId = cardId; + }, + onAddCard: () async { + final newCardId = + await PaymentCardFormSheet.show(context); + if (newCardId != null) { + controller.selectedPaymentCardId = newCardId; + } + }, + onManageCards: () { + Navigator.of(context) + .pushNamed(AppRoutes.paymentCardManagement); + }, + ), ], ), ), diff --git a/lib/widgets/detail/detail_header_section.dart b/lib/widgets/detail/detail_header_section.dart index e716b11..d533890 100644 --- a/lib/widgets/detail/detail_header_section.dart +++ b/lib/widgets/detail/detail_header_section.dart @@ -3,7 +3,12 @@ import 'package:provider/provider.dart'; import '../../models/subscription_model.dart'; import '../../controllers/detail_screen_controller.dart'; import '../../providers/locale_provider.dart'; +import '../../providers/payment_card_provider.dart'; import '../../services/currency_util.dart'; +import '../../utils/payment_card_utils.dart'; +import '../../models/payment_card_model.dart'; +import '../payment_card/payment_card_form_sheet.dart'; +import '../../routes/app_routes.dart'; import '../website_icon.dart'; import '../../l10n/app_localizations.dart'; @@ -30,6 +35,10 @@ class DetailHeaderSection extends StatelessWidget { return Consumer( builder: (context, controller, child) { final baseColor = controller.getCardColor(); + final paymentCardProvider = context.watch(); + final paymentCard = paymentCardProvider.getCardById( + controller.selectedPaymentCardId ?? subscription.paymentCardId, + ); return Container( height: 320, @@ -172,6 +181,11 @@ class DetailHeaderSection extends StatelessWidget { .withValues(alpha: 0.8), ), ), + const SizedBox(height: 12), + _buildPaymentCardChip( + context, + paymentCard, + ), ], ), ), @@ -268,6 +282,104 @@ class DetailHeaderSection extends StatelessWidget { return cycle; } } + + Widget _buildPaymentCardChip( + BuildContext context, + PaymentCardModel? card, + ) { + final loc = AppLocalizations.of(context); + + if (card == null) { + return GestureDetector( + onTap: () { + Navigator.of(context).pushNamed(AppRoutes.paymentCardManagement); + }, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(24), + border: Border.all( + color: Colors.white.withValues(alpha: 0.2), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.credit_card_off_rounded, + color: Colors.white, + size: 16, + ), + const SizedBox(width: 8), + Text( + loc.paymentCardUnassigned, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: Colors.white, + ), + ), + const SizedBox(width: 8), + Icon( + Icons.arrow_forward_ios_rounded, + color: Colors.white.withValues(alpha: 0.7), + size: 14, + ), + ], + ), + ), + ); + } + + final color = PaymentCardUtils.colorFromHex(card.colorHex); + final icon = PaymentCardUtils.iconForName(card.iconName); + + return GestureDetector( + onTap: () async { + await PaymentCardFormSheet.show(context, card: card); + }, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 9), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(28), + border: Border.all( + color: color.withValues(alpha: 0.5), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + CircleAvatar( + radius: 14, + backgroundColor: Colors.white, + child: Icon( + icon, + size: 16, + color: color, + ), + ), + const SizedBox(width: 10), + Text( + '${card.issuerName} · ****${card.last4}', + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + ), + const SizedBox(width: 8), + Icon( + Icons.edit_rounded, + size: 16, + color: Colors.white.withValues(alpha: 0.8), + ), + ], + ), + ), + ); + } } /// 정보 표시 컬럼 diff --git a/lib/widgets/detail/detail_payment_info_section.dart b/lib/widgets/detail/detail_payment_info_section.dart new file mode 100644 index 0000000..5ee9b3b --- /dev/null +++ b/lib/widgets/detail/detail_payment_info_section.dart @@ -0,0 +1,196 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../controllers/detail_screen_controller.dart'; +import '../../models/payment_card_model.dart'; +import '../../providers/payment_card_provider.dart'; +import '../../routes/app_routes.dart'; +import '../../utils/payment_card_utils.dart'; +import '../payment_card/payment_card_form_sheet.dart'; +import '../../l10n/app_localizations.dart'; + +/// 상세 화면 결제 정보 섹션 +/// 현재 구독에 연결된 결제수단 정보를 요약하여 보여준다. +class DetailPaymentInfoSection extends StatelessWidget { + final DetailScreenController controller; + final Animation fadeAnimation; + final Animation slideAnimation; + + const DetailPaymentInfoSection({ + super.key, + required this.controller, + required this.fadeAnimation, + required this.slideAnimation, + }); + + @override + Widget build(BuildContext context) { + return Consumer2( + builder: (context, detailController, paymentCardProvider, child) { + final baseColor = detailController.getCardColor(); + final paymentCard = paymentCardProvider.getCardById( + detailController.selectedPaymentCardId ?? + detailController.subscription.paymentCardId, + ); + + return FadeTransition( + opacity: fadeAnimation, + child: SlideTransition( + position: slideAnimation, + child: Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3), + width: 1, + ), + ), + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: baseColor.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + Icons.credit_card_rounded, + color: baseColor, + size: 22, + ), + ), + const SizedBox(width: 12), + Text( + AppLocalizations.of(context).paymentCard, + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + color: Theme.of(context).colorScheme.onSurface, + ), + ), + const Spacer(), + TextButton.icon( + onPressed: () { + Navigator.of(context) + .pushNamed(AppRoutes.paymentCardManagement); + }, + icon: const Icon(Icons.settings_rounded, size: 18), + label: Text( + AppLocalizations.of(context).paymentCardManagement, + ), + ), + ], + ), + const SizedBox(height: 16), + _PaymentCardInfoTile( + card: paymentCard, + onTap: () async { + if (paymentCard != null) { + await PaymentCardFormSheet.show( + context, + card: paymentCard, + ); + } else { + Navigator.of(context) + .pushNamed(AppRoutes.paymentCardManagement); + } + }, + ), + ], + ), + ), + ), + ), + ); + }, + ); + } +} + +class _PaymentCardInfoTile extends StatelessWidget { + final PaymentCardModel? card; + final VoidCallback onTap; + + const _PaymentCardInfoTile({ + required this.card, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + final loc = AppLocalizations.of(context); + final hasCard = card != null; + final chipColor = hasCard + ? PaymentCardUtils.colorFromHex(card!.colorHex) + : scheme.onSurfaceVariant; + final icon = hasCard + ? PaymentCardUtils.iconForName(card!.iconName) + : Icons.credit_card_off_rounded; + + return Material( + color: scheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(16), + child: InkWell( + borderRadius: BorderRadius.circular(16), + onTap: onTap, + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + CircleAvatar( + radius: 20, + backgroundColor: chipColor.withValues(alpha: 0.15), + child: Icon( + icon, + color: chipColor, + size: 20, + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + loc.paymentCard, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: scheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 4), + Text( + hasCard + ? '${card!.issuerName} · ****${card!.last4}' + : loc.paymentCardUnassigned, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w700, + color: scheme.onSurface, + ), + ), + ], + ), + ), + Icon( + hasCard ? Icons.edit_rounded : Icons.add_rounded, + color: scheme.onSurfaceVariant, + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/widgets/home_content.dart b/lib/widgets/home_content.dart index 3a93cc2..d270162 100644 --- a/lib/widgets/home_content.dart +++ b/lib/widgets/home_content.dart @@ -1,16 +1,18 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -import '../providers/subscription_provider.dart'; -import '../providers/category_provider.dart'; -import '../utils/subscription_category_helper.dart'; -import '../widgets/native_ad_widget.dart'; -import '../widgets/main_summary_card.dart'; -import '../widgets/subscription_list_widget.dart'; -import '../widgets/empty_state_widget.dart'; -// import '../theme/app_colors.dart'; -import '../l10n/app_localizations.dart'; +import 'package:shared_preferences/shared_preferences.dart'; -class HomeContent extends StatelessWidget { +import '../l10n/app_localizations.dart'; +import '../providers/category_provider.dart'; +import '../providers/payment_card_provider.dart'; +import '../providers/subscription_provider.dart'; +import '../utils/subscription_grouping_helper.dart'; +import '../widgets/empty_state_widget.dart'; +import '../widgets/main_summary_card.dart'; +import '../widgets/native_ad_widget.dart'; +import '../widgets/subscription_list_widget.dart'; + +class HomeContent extends StatefulWidget { final AnimationController fadeController; final AnimationController rotateController; final AnimationController slideController; @@ -31,10 +33,53 @@ class HomeContent extends StatelessWidget { }); @override - Widget build(BuildContext context) { - final provider = context.watch(); + State createState() => _HomeContentState(); +} - if (provider.isLoading) { +class _HomeContentState extends State { + static const _groupingPrefKey = 'home_grouping_mode'; + SubscriptionGroupingMode _groupingMode = SubscriptionGroupingMode.category; + + @override + void initState() { + super.initState(); + _loadGroupingPreference(); + } + + Future _loadGroupingPreference() async { + final prefs = await SharedPreferences.getInstance(); + final stored = prefs.getString(_groupingPrefKey); + if (stored == 'paymentCard') { + setState(() { + _groupingMode = SubscriptionGroupingMode.paymentCard; + }); + } + } + + Future _saveGroupingPreference(SubscriptionGroupingMode mode) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString( + _groupingPrefKey, + mode == SubscriptionGroupingMode.paymentCard + ? 'paymentCard' + : 'category'); + } + + void _updateGroupingMode(SubscriptionGroupingMode mode) { + if (_groupingMode == mode) return; + setState(() { + _groupingMode = mode; + }); + _saveGroupingPreference(mode); + } + + @override + Widget build(BuildContext context) { + final subscriptionProvider = context.watch(); + final categoryProvider = context.watch(); + final paymentCardProvider = context.watch(); + + if (subscriptionProvider.isLoading) { return Center( child: CircularProgressIndicator( valueColor: AlwaysStoppedAnimation( @@ -44,32 +89,30 @@ class HomeContent extends StatelessWidget { ); } - if (provider.subscriptions.isEmpty) { + if (subscriptionProvider.subscriptions.isEmpty) { return EmptyStateWidget( - fadeController: fadeController, - rotateController: rotateController, - slideController: slideController, - onAddPressed: onAddPressed, + fadeController: widget.fadeController, + rotateController: widget.rotateController, + slideController: widget.slideController, + onAddPressed: widget.onAddPressed, ); } - // 카테고리별 구독 구분 - final categoryProvider = - Provider.of(context, listen: false); - final categorizedSubscriptions = - SubscriptionCategoryHelper.categorizeSubscriptions( - provider.subscriptions, - categoryProvider, - context, + final groupedSubscriptions = SubscriptionGroupingHelper.buildGroups( + context: context, + subscriptions: subscriptionProvider.subscriptions, + mode: _groupingMode, + categoryProvider: categoryProvider, + paymentCardProvider: paymentCardProvider, ); return RefreshIndicator( onRefresh: () async { - await provider.refreshSubscriptions(); + await subscriptionProvider.refreshSubscriptions(); }, color: Theme.of(context).colorScheme.primary, child: CustomScrollView( - controller: scrollController, + controller: widget.scrollController, physics: const BouncingScrollPhysics(), slivers: [ SliverToBoxAdapter( @@ -86,13 +129,13 @@ class HomeContent extends StatelessWidget { begin: const Offset(0, 0.2), end: Offset.zero, ).animate(CurvedAnimation( - parent: slideController, curve: Curves.easeOutCubic)), + parent: widget.slideController, curve: Curves.easeOutCubic)), child: MainScreenSummaryCard( - provider: provider, - fadeController: fadeController, - pulseController: pulseController, - waveController: waveController, - slideController: slideController, + provider: subscriptionProvider, + fadeController: widget.fadeController, + pulseController: widget.pulseController, + waveController: widget.waveController, + slideController: widget.slideController, ), ), ), @@ -107,7 +150,8 @@ class HomeContent extends StatelessWidget { begin: const Offset(-0.2, 0), end: Offset.zero, ).animate(CurvedAnimation( - parent: slideController, curve: Curves.easeOutCubic)), + parent: widget.slideController, + curve: Curves.easeOutCubic)), child: Text( AppLocalizations.of(context).mySubscriptions, style: Theme.of(context).textTheme.titleLarge?.copyWith( @@ -120,12 +164,13 @@ class HomeContent extends StatelessWidget { begin: const Offset(0.2, 0), end: Offset.zero, ).animate(CurvedAnimation( - parent: slideController, curve: Curves.easeOutCubic)), + parent: widget.slideController, + curve: Curves.easeOutCubic)), child: Row( children: [ Text( - AppLocalizations.of(context) - .subscriptionCount(provider.subscriptions.length), + AppLocalizations.of(context).subscriptionCount( + subscriptionProvider.subscriptions.length), style: TextStyle( fontSize: 14, fontWeight: FontWeight.w600, @@ -145,9 +190,33 @@ class HomeContent extends StatelessWidget { ), ), ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), + child: Wrap( + spacing: 8, + children: [ + ChoiceChip( + label: Text(AppLocalizations.of(context).category), + selected: + _groupingMode == SubscriptionGroupingMode.category, + onSelected: (_) => + _updateGroupingMode(SubscriptionGroupingMode.category), + ), + ChoiceChip( + label: Text(AppLocalizations.of(context).paymentCard), + selected: + _groupingMode == SubscriptionGroupingMode.paymentCard, + onSelected: (_) => _updateGroupingMode( + SubscriptionGroupingMode.paymentCard), + ), + ], + ), + ), + ), SubscriptionListWidget( - categorizedSubscriptions: categorizedSubscriptions, - fadeController: fadeController, + groups: groupedSubscriptions, + fadeController: widget.fadeController, ), SliverToBoxAdapter( child: SizedBox( diff --git a/lib/widgets/payment_card/payment_card_form_sheet.dart b/lib/widgets/payment_card/payment_card_form_sheet.dart new file mode 100644 index 0000000..8a27b23 --- /dev/null +++ b/lib/widgets/payment_card/payment_card_form_sheet.dart @@ -0,0 +1,290 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:provider/provider.dart'; + +import '../../l10n/app_localizations.dart'; +import '../../models/payment_card_model.dart'; +import '../../providers/payment_card_provider.dart'; +import '../../utils/payment_card_utils.dart'; + +class PaymentCardFormSheet extends StatefulWidget { + final PaymentCardModel? card; + final String? initialIssuerName; + final String? initialLast4; + final String? initialColorHex; + final String? initialIconName; + + const PaymentCardFormSheet({ + super.key, + this.card, + this.initialIssuerName, + this.initialLast4, + this.initialColorHex, + this.initialIconName, + }); + + static Future show( + BuildContext context, { + PaymentCardModel? card, + String? initialIssuerName, + String? initialLast4, + String? initialColorHex, + String? initialIconName, + }) async { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + builder: (_) => PaymentCardFormSheet( + card: card, + initialIssuerName: initialIssuerName, + initialLast4: initialLast4, + initialColorHex: initialColorHex, + initialIconName: initialIconName, + ), + ); + } + + @override + State createState() => _PaymentCardFormSheetState(); +} + +class _PaymentCardFormSheetState extends State { + final _formKey = GlobalKey(); + late TextEditingController _issuerController; + late TextEditingController _last4Controller; + late String _selectedColor; + late String _selectedIcon; + late bool _isDefault; + bool _isSaving = false; + + @override + void initState() { + super.initState(); + _issuerController = TextEditingController( + text: widget.card?.issuerName ?? widget.initialIssuerName ?? '', + ); + _last4Controller = TextEditingController( + text: widget.card?.last4 ?? widget.initialLast4 ?? '', + ); + _selectedColor = widget.card?.colorHex ?? + widget.initialColorHex ?? + PaymentCardUtils.colorPalette.first; + _selectedIcon = widget.card?.iconName ?? + widget.initialIconName ?? + PaymentCardUtils.iconMap.keys.first; + _isDefault = widget.card?.isDefault ?? false; + } + + @override + void dispose() { + _issuerController.dispose(); + _last4Controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final loc = AppLocalizations.of(context); + final isEditing = widget.card != null; + + return Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom, + ), + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(24, 24, 24, 32), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + isEditing ? loc.editPaymentCard : loc.addPaymentCard, + style: Theme.of(context).textTheme.titleLarge, + ), + const Spacer(), + IconButton( + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.close), + ), + ], + ), + const SizedBox(height: 16), + Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextFormField( + controller: _issuerController, + decoration: InputDecoration( + labelText: loc.paymentCardIssuer, + border: const OutlineInputBorder(), + ), + textInputAction: TextInputAction.next, + validator: (value) { + if (value == null || value.trim().isEmpty) { + return loc.requiredFieldsError; + } + return null; + }, + ), + const SizedBox(height: 16), + TextFormField( + controller: _last4Controller, + decoration: InputDecoration( + labelText: loc.paymentCardLast4, + border: const OutlineInputBorder(), + counterText: '', + ), + keyboardType: TextInputType.number, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + LengthLimitingTextInputFormatter(4), + ], + validator: (value) { + if (value == null || value.length != 4) { + return loc.paymentCardLast4; + } + return null; + }, + ), + const SizedBox(height: 16), + Text( + loc.paymentCardColor, + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 8, + children: PaymentCardUtils.colorPalette.map((hex) { + final color = PaymentCardUtils.colorFromHex(hex); + final selected = _selectedColor == hex; + return GestureDetector( + onTap: () { + setState(() { + _selectedColor = hex; + }); + }, + child: Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: color, + shape: BoxShape.circle, + border: Border.all( + color: selected + ? Theme.of(context).colorScheme.onSurface + : Colors.transparent, + width: 2, + ), + ), + ), + ); + }).toList(), + ), + const SizedBox(height: 16), + Text( + loc.paymentCardIcon, + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 8, + children: PaymentCardUtils.iconMap.entries.map((entry) { + final selected = _selectedIcon == entry.key; + return ChoiceChip( + label: Icon(entry.value, + color: selected + ? Theme.of(context).colorScheme.onPrimary + : Theme.of(context).colorScheme.onSurface), + selected: selected, + onSelected: (_) { + setState(() { + _selectedIcon = entry.key; + }); + }, + selectedColor: Theme.of(context).colorScheme.primary, + backgroundColor: Theme.of(context) + .colorScheme + .surfaceContainerHighest, + ); + }).toList(), + ), + const SizedBox(height: 16), + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: Text(loc.setAsDefaultCard), + value: _isDefault, + onChanged: (value) { + setState(() { + _isDefault = value; + }); + }, + ), + const SizedBox(height: 24), + SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: _isSaving ? null : _handleSubmit, + child: _isSaving + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Text(loc.save), + ), + ), + ], + ), + ), + ], + ), + ), + ); + } + + Future _handleSubmit() async { + if (!_formKey.currentState!.validate()) return; + setState(() { + _isSaving = true; + }); + try { + final provider = context.read(); + String cardId; + if (widget.card == null) { + final card = await provider.addCard( + issuerName: _issuerController.text.trim(), + last4: _last4Controller.text.trim(), + colorHex: _selectedColor, + iconName: _selectedIcon, + isDefault: _isDefault, + ); + cardId = card.id; + } else { + widget.card! + ..issuerName = _issuerController.text.trim() + ..last4 = _last4Controller.text.trim() + ..colorHex = _selectedColor + ..iconName = _selectedIcon + ..isDefault = _isDefault; + await provider.updateCard(widget.card!); + cardId = widget.card!.id; + } + if (mounted) { + Navigator.of(context).pop(cardId); + } + } finally { + if (mounted) { + setState(() { + _isSaving = false; + }); + } + } + } +} diff --git a/lib/widgets/payment_card/payment_card_selector.dart b/lib/widgets/payment_card/payment_card_selector.dart new file mode 100644 index 0000000..5bda12d --- /dev/null +++ b/lib/widgets/payment_card/payment_card_selector.dart @@ -0,0 +1,142 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../l10n/app_localizations.dart'; +import '../../models/payment_card_model.dart'; +import '../../providers/payment_card_provider.dart'; +import '../../utils/payment_card_utils.dart'; + +class PaymentCardSelector extends StatelessWidget { + final String? selectedCardId; + final ValueChanged onChanged; + final Future Function()? onAddCard; + final VoidCallback? onManageCards; + + const PaymentCardSelector({ + super.key, + required this.selectedCardId, + required this.onChanged, + this.onAddCard, + this.onManageCards, + }); + + @override + Widget build(BuildContext context) { + return Consumer( + builder: (context, provider, child) { + final loc = AppLocalizations.of(context); + final cards = provider.cards; + final unassignedSelected = selectedCardId == null; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + Semantics( + label: loc.paymentCardUnassigned, + selected: unassignedSelected, + button: true, + child: ChoiceChip( + label: Text(loc.paymentCardUnassigned), + selected: unassignedSelected, + onSelected: (_) => onChanged(null), + avatar: const Icon(Icons.credit_card_off_rounded, size: 18), + ), + ), + ...cards.map((card) => _PaymentCardChip( + card: card, + isSelected: selectedCardId == card.id, + onSelected: () => onChanged(card.id), + )), + ], + ), + const SizedBox(height: 8), + Row( + children: [ + TextButton.icon( + onPressed: cards.isEmpty && onAddCard == null + ? null + : () async { + if (onAddCard != null) { + await onAddCard!(); + } + }, + icon: const Icon(Icons.add), + label: Text(loc.addNewCard), + ), + const SizedBox(width: 8), + TextButton( + onPressed: onManageCards, + child: Text(loc.managePaymentCards), + ), + ], + ), + if (cards.isEmpty) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text( + loc.noPaymentCards, + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant, + fontSize: 13, + ), + ), + ), + ], + ); + }, + ); + } +} + +class _PaymentCardChip extends StatelessWidget { + final PaymentCardModel card; + final bool isSelected; + final VoidCallback onSelected; + + const _PaymentCardChip({ + required this.card, + required this.isSelected, + required this.onSelected, + }); + + @override + Widget build(BuildContext context) { + final color = PaymentCardUtils.colorFromHex(card.colorHex); + final icon = PaymentCardUtils.iconForName(card.iconName); + final cs = Theme.of(context).colorScheme; + final labelText = '${card.issuerName} · ****${card.last4}'; + return Semantics( + label: labelText, + selected: isSelected, + button: true, + child: ChoiceChip( + avatar: CircleAvatar( + backgroundColor: + isSelected ? cs.onPrimary : color.withValues(alpha: 0.15), + child: Icon( + icon, + color: isSelected ? color : cs.onSurface, + size: 16, + ), + ), + label: Text(labelText), + selected: isSelected, + onSelected: (_) => onSelected(), + selectedColor: color, + labelStyle: TextStyle( + color: isSelected ? cs.onPrimary : cs.onSurface, + fontWeight: FontWeight.w600, + ), + backgroundColor: cs.surface, + side: BorderSide( + color: isSelected + ? Colors.transparent + : cs.outline.withValues(alpha: 0.5), + ), + ), + ); + } +} diff --git a/lib/widgets/sms_scan/subscription_card_widget.dart b/lib/widgets/sms_scan/subscription_card_widget.dart index 5c5158e..76d23fc 100644 --- a/lib/widgets/sms_scan/subscription_card_widget.dart +++ b/lib/widgets/sms_scan/subscription_card_widget.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../../models/subscription.dart'; +import '../../models/payment_card_suggestion.dart'; import '../../providers/category_provider.dart'; import '../../providers/locale_provider.dart'; import '../../widgets/themed_text.dart'; @@ -10,6 +11,7 @@ import '../../widgets/common/form_fields/base_text_field.dart'; import '../../widgets/common/form_fields/category_selector.dart'; import '../../widgets/common/snackbar/app_snackbar.dart'; import '../../widgets/native_ad_widget.dart'; +import '../../widgets/payment_card/payment_card_selector.dart'; import '../../services/currency_util.dart'; import '../../utils/sms_scan/date_formatter.dart'; import '../../utils/sms_scan/category_icon_mapper.dart'; @@ -20,8 +22,16 @@ class SubscriptionCardWidget extends StatefulWidget { final TextEditingController websiteUrlController; final String? selectedCategoryId; final Function(String?) onCategoryChanged; + final String? selectedPaymentCardId; + final Function(String?) onPaymentCardChanged; + final Future Function()? onAddCard; + final VoidCallback? onManageCards; final VoidCallback onAdd; final VoidCallback onSkip; + final PaymentCardSuggestion? detectedCardSuggestion; + final bool showDetectedCardShortcut; + final Future Function(PaymentCardSuggestion suggestion)? + onAddDetectedCard; const SubscriptionCardWidget({ super.key, @@ -29,8 +39,15 @@ class SubscriptionCardWidget extends StatefulWidget { required this.websiteUrlController, this.selectedCategoryId, required this.onCategoryChanged, + required this.selectedPaymentCardId, + required this.onPaymentCardChanged, + this.onAddCard, + this.onManageCards, required this.onAdd, required this.onSkip, + this.detectedCardSuggestion, + this.showDetectedCardShortcut = false, + this.onAddDetectedCard, }); @override @@ -246,6 +263,39 @@ class _SubscriptionCardWidgetState extends State { ), const SizedBox(height: 24), + // 결제수단 선택 + ThemedText( + AppLocalizations.of(context).paymentCard, + fontWeight: FontWeight.w500, + opacity: 0.7, + ), + const SizedBox(height: 8), + PaymentCardSelector( + selectedCardId: widget.selectedPaymentCardId, + onChanged: widget.onPaymentCardChanged, + onAddCard: widget.onAddCard, + onManageCards: widget.onManageCards, + ), + if (widget.showDetectedCardShortcut && + widget.detectedCardSuggestion != null) ...[ + const SizedBox(height: 12), + _DetectedCardSuggestionBanner( + suggestion: widget.detectedCardSuggestion!, + onAdd: widget.onAddDetectedCard, + ), + ], + if (widget.selectedPaymentCardId == null) ...[ + const SizedBox(height: 8), + Text( + AppLocalizations.of(context).paymentCardUnassignedWarning, + style: TextStyle( + fontSize: 12, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + const SizedBox(height: 24), + // 웹사이트 URL 입력 필드 BaseTextField( controller: widget.websiteUrlController, @@ -297,3 +347,84 @@ class _SubscriptionCardWidgetState extends State { return CategoryIconMapper.getCategoryColor(category); } } + +class _DetectedCardSuggestionBanner extends StatelessWidget { + final PaymentCardSuggestion suggestion; + final Future Function(PaymentCardSuggestion suggestion)? onAdd; + + const _DetectedCardSuggestionBanner({ + required this.suggestion, + this.onAdd, + }); + + @override + Widget build(BuildContext context) { + final loc = AppLocalizations.of(context); + final scheme = Theme.of(context).colorScheme; + + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: scheme.secondaryContainer, + borderRadius: BorderRadius.circular(16), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: scheme.onSecondaryContainer.withValues(alpha: 0.1), + shape: BoxShape.circle, + ), + child: Icon( + Icons.auto_fix_high_rounded, + color: scheme.onSecondaryContainer, + size: 20, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + loc.detectedPaymentCard, + style: TextStyle( + fontWeight: FontWeight.w600, + color: scheme.onSecondaryContainer, + ), + ), + const SizedBox(height: 4), + Text( + loc.detectedPaymentCardDescription( + suggestion.issuerName, + suggestion.last4 ?? '****', + ), + style: TextStyle( + fontSize: 13, + color: scheme.onSecondaryContainer.withValues(alpha: 0.9), + ), + ), + ], + ), + ), + const SizedBox(width: 12), + ElevatedButton.icon( + onPressed: onAdd == null + ? null + : () async { + await onAdd!(suggestion); + }, + style: ElevatedButton.styleFrom( + backgroundColor: scheme.onSecondaryContainer, + foregroundColor: scheme.secondaryContainer, + ), + icon: const Icon(Icons.add_rounded, size: 16), + label: Text(loc.addDetectedPaymentCard), + ), + ], + ), + ); + } +} diff --git a/lib/widgets/subscription_card.dart b/lib/widgets/subscription_card.dart index b41093b..f1210ef 100644 --- a/lib/widgets/subscription_card.dart +++ b/lib/widgets/subscription_card.dart @@ -2,10 +2,12 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../models/subscription_model.dart'; import '../providers/category_provider.dart'; +import '../providers/payment_card_provider.dart'; import '../providers/locale_provider.dart'; import '../services/subscription_url_matcher.dart'; import '../services/currency_util.dart'; import '../utils/billing_date_util.dart'; +import '../utils/payment_card_utils.dart'; import 'website_icon.dart'; import 'app_navigator.dart'; // import '../theme/app_colors.dart'; @@ -299,6 +301,7 @@ class _SubscriptionCardState extends State Widget build(BuildContext context) { // LocaleProvider를 watch하여 언어 변경시 자동 업데이트 final localeProvider = context.watch(); + final paymentCardProvider = context.watch(); // 언어가 변경되면 displayName 다시 로드 WidgetsBinding.instance.addPostFrameCallback((_) { @@ -464,7 +467,10 @@ class _SubscriptionCardState extends State ], ), - const SizedBox(height: 6), + const SizedBox(height: 8), + _buildPaymentCardBadge( + context, paymentCardProvider), + const SizedBox(height: 8), // 가격 정보 Row( @@ -673,4 +679,57 @@ class _SubscriptionCardState extends State ), ); } + + Widget _buildPaymentCardBadge( + BuildContext context, PaymentCardProvider provider) { + final scheme = Theme.of(context).colorScheme; + final loc = AppLocalizations.of(context); + final card = provider.getCardById(widget.subscription.paymentCardId); + + if (card == null) { + return Chip( + avatar: Icon( + Icons.credit_card_off_rounded, + size: 14, + color: scheme.onSurfaceVariant, + ), + label: Text( + loc.paymentCardUnassigned, + style: TextStyle( + fontSize: 12, + color: scheme.onSurfaceVariant, + ), + ), + backgroundColor: scheme.surfaceContainerHighest.withValues(alpha: 0.5), + padding: const EdgeInsets.symmetric(horizontal: 6), + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + ); + } + + final color = PaymentCardUtils.colorFromHex(card.colorHex); + final icon = PaymentCardUtils.iconForName(card.iconName); + + return Chip( + avatar: CircleAvatar( + backgroundColor: Colors.white, + child: Icon( + icon, + size: 14, + color: color, + ), + ), + label: Text( + '${card.issuerName} · ****${card.last4}', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: color, + ), + ), + side: BorderSide(color: color.withValues(alpha: 0.3)), + backgroundColor: color.withValues(alpha: 0.12), + padding: const EdgeInsets.symmetric(horizontal: 8), + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + ); + } } diff --git a/lib/widgets/subscription_group_header.dart b/lib/widgets/subscription_group_header.dart new file mode 100644 index 0000000..f5d1218 --- /dev/null +++ b/lib/widgets/subscription_group_header.dart @@ -0,0 +1,164 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; + +import '../l10n/app_localizations.dart'; +import '../utils/payment_card_utils.dart'; +import '../utils/subscription_grouping_helper.dart'; + +class SubscriptionGroupHeader extends StatelessWidget { + final SubscriptionGroupData group; + final int subscriptionCount; + final double totalCostUSD; + final double totalCostKRW; + final double totalCostJPY; + final double totalCostCNY; + + const SubscriptionGroupHeader({ + super.key, + required this.group, + required this.subscriptionCount, + required this.totalCostUSD, + required this.totalCostKRW, + required this.totalCostJPY, + required this.totalCostCNY, + }); + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Row( + children: [ + if (group.mode == SubscriptionGroupingMode.paymentCard && + group.paymentCard != null) + _PaymentCardAvatar(colorHex: group.paymentCard!.colorHex) + else if (group.mode == SubscriptionGroupingMode.paymentCard) + const _PaymentCardAvatar(), + if (group.mode == SubscriptionGroupingMode.paymentCard) + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + group.title, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w700, + color: scheme.onSurface, + ), + ), + if (group.subtitle != null) + Text( + group.subtitle!, + style: TextStyle( + fontSize: 13, + color: scheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ], + ), + ), + Text( + _buildCostDisplay(context), + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: scheme.onSurfaceVariant, + ), + ), + ], + ), + const SizedBox(height: 8), + Divider( + height: 1, + thickness: 1, + color: scheme.outline.withValues(alpha: 0.3), + ), + ], + ), + ); + } + + String _buildCostDisplay(BuildContext context) { + final parts = []; + parts + .add(AppLocalizations.of(context).subscriptionCount(subscriptionCount)); + + final currencyParts = []; + + if (totalCostUSD > 0) { + final formatter = NumberFormat.currency( + locale: 'en_US', + symbol: '\$', + decimalDigits: 2, + ); + currencyParts.add(formatter.format(totalCostUSD)); + } + if (totalCostKRW > 0) { + final formatter = NumberFormat.currency( + locale: 'ko_KR', + symbol: '₩', + decimalDigits: 0, + ); + currencyParts.add(formatter.format(totalCostKRW)); + } + if (totalCostJPY > 0) { + final formatter = NumberFormat.currency( + locale: 'ja_JP', + symbol: '¥', + decimalDigits: 0, + ); + currencyParts.add(formatter.format(totalCostJPY)); + } + if (totalCostCNY > 0) { + final formatter = NumberFormat.currency( + locale: 'zh_CN', + symbol: '¥', + decimalDigits: 2, + ); + currencyParts.add(formatter.format(totalCostCNY)); + } + + if (currencyParts.isNotEmpty) { + parts.add(currencyParts.join(' + ')); + } + + return parts.join(' · '); + } +} + +class _PaymentCardAvatar extends StatelessWidget { + final String? colorHex; + + const _PaymentCardAvatar({this.colorHex}); + + @override + Widget build(BuildContext context) { + final color = colorHex != null + ? PaymentCardUtils.colorFromHex(colorHex!) + : Theme.of(context).colorScheme.outlineVariant; + final icon = + colorHex != null ? Icons.credit_card : Icons.credit_card_off_rounded; + return CircleAvatar( + radius: 18, + backgroundColor: color.withValues(alpha: 0.15), + child: Icon( + icon, + color: color, + size: 16, + ), + ); + } +} diff --git a/lib/widgets/subscription_list_widget.dart b/lib/widgets/subscription_list_widget.dart index 762ad28..4bc668d 100644 --- a/lib/widgets/subscription_list_widget.dart +++ b/lib/widgets/subscription_list_widget.dart @@ -1,66 +1,52 @@ import 'package:flutter/material.dart'; import '../models/subscription_model.dart'; -import '../widgets/category_header_widget.dart'; +import '../widgets/subscription_group_header.dart'; import '../widgets/swipeable_subscription_card.dart'; import '../widgets/staggered_list_animation.dart'; import '../widgets/app_navigator.dart'; import 'package:provider/provider.dart'; import '../providers/subscription_provider.dart'; import '../providers/locale_provider.dart'; -import '../providers/category_provider.dart'; 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'; +import '../utils/subscription_grouping_helper.dart'; /// 카테고리별로 구독 목록을 표시하는 위젯 class SubscriptionListWidget extends StatelessWidget { - final Map> categorizedSubscriptions; + final List groups; final AnimationController fadeController; const SubscriptionListWidget({ super.key, - required this.categorizedSubscriptions, + required this.groups, required this.fadeController, }); @override Widget build(BuildContext context) { - // 카테고리 키 목록 (정렬된) - final categories = categorizedSubscriptions.keys.toList(); + final sections = groups; return SliverList( delegate: SliverChildBuilderDelegate( (context, index) { - final category = categories[index]; - final subscriptions = categorizedSubscriptions[category]!; + final group = sections[index]; + final subscriptions = group.subscriptions; return Padding( padding: const EdgeInsets.only(bottom: 8.0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // 카테고리 헤더 - Padding( - padding: const EdgeInsets.fromLTRB(20, 16, 20, 8), - child: Consumer( - builder: (context, categoryProvider, child) { - return CategoryHeaderWidget( - categoryName: categoryProvider.getLocalizedCategoryName( - context, category), - subscriptionCount: subscriptions.length, - totalCostUSD: - _calculateTotalByCurrency(subscriptions, 'USD'), - totalCostKRW: - _calculateTotalByCurrency(subscriptions, 'KRW'), - totalCostJPY: - _calculateTotalByCurrency(subscriptions, 'JPY'), - totalCostCNY: - _calculateTotalByCurrency(subscriptions, 'CNY'), - ); - }, - ), + SubscriptionGroupHeader( + group: group, + subscriptionCount: subscriptions.length, + totalCostUSD: _calculateTotalByCurrency(subscriptions, 'USD'), + totalCostKRW: _calculateTotalByCurrency(subscriptions, 'KRW'), + totalCostJPY: _calculateTotalByCurrency(subscriptions, 'JPY'), + totalCostCNY: _calculateTotalByCurrency(subscriptions, 'CNY'), ), // 카테고리별 구독 목록 FadeTransition( @@ -169,7 +155,7 @@ class SubscriptionListWidget extends StatelessWidget { ), ); }, - childCount: categories.length, + childCount: sections.length, ), ); }