6 Commits

Author SHA1 Message Date
JiWoong Sul
2cd46a303e feat: improve sms scan review and detail layouts 2025-11-14 19:33:32 +09:00
JiWoong Sul
a9f42f6f01 fix: adjust subscription card layout 2025-11-14 17:14:16 +09:00
JiWoong Sul
132ae758de feat: add payment card grouping and analysis 2025-11-14 16:53:41 +09:00
JiWoong Sul
cba7d082bd docs: outline payment card grouping plan 2025-11-14 14:29:36 +09:00
JiWoong Sul
8cec03f181 feat: enhance sms scanner repeat detection 2025-11-14 14:29:32 +09:00
JiWoong Sul
7ace3afaf3 Merge branch 'codex/fix-notification-reliability'
Some checks failed
Flutter CI / build (push) Has been cancelled
2025-09-19 18:15:36 +09:00
42 changed files with 3691 additions and 741 deletions

View File

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

122
doc/payment_card_plan.md Normal file
View File

@@ -0,0 +1,122 @@
# 결제수단 구분 확장 계획
## 배경
- 현재 홈 화면은 카테고리별 구독 목록만 제공하며, 결제 카드 기준으로 필터링하거나 시각적으로 구분할 수 없음.
- 사용자 요청: 카드 회사명과 마지막 4자리로 구독을 분류해 데이터/UX 양쪽 모두에서 카드별 인사이트를 제공.
## 목표
- 구독 데이터를 카드 단위로 매핑할 수 있는 스키마 확장.
- 카드 정보를 한 번만 등록하도록 관리 화면을 제공해 재사용성 확보.
- 홈 화면에서 카테고리/카드 뷰를 토글하며, 헤더·리스트·분석 카드가 카드 정보를 시각적으로 노출.
- 모든 변경은 기존 카테고리 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는 미사용 값을 배정.
- `PaymentCardProvider`에서 Hive box(`payment_cards`)를 관리하고 CRUD, 정렬, 기본값 선택 기능 제공.
- `main.dart` 초기화 시 카드 어댑터 등록 → Provider 주입.
- 구독 저장 로직(`SubscriptionProvider.add/update`)과 SMS/수동 추가 컨트롤러에서 `paymentCardId`를 인자로 전달.
## 카드 정보 입력 UX
- 전용 관리 화면: 설정 > “결제수단 관리” 또는 독립 `PaymentCardManagementScreen`.
- 필수 입력: 회사명(자유 텍스트), 마지막 4자리(숫자 4자리), 선택형 색상/아이콘.
- 리스트 정렬, 편집, 삭제, 기본 카드 지정, 구독 수 연동 배지 표시.
- 컨텍스트 내 빠른 등록: 구독 추가/수정 폼, SMS 스캔 리뷰 화면 등에서 “+ 새 카드” 버튼을 눌렀을 때 시트/모달로 간단 등록 가능.
- 구독 추가/수정 폼에 `PaymentCardSelector`를 추가:
- 드롭다운/검색형 목록에 등록된 카드를 노출하고, 최근 사용 카드가 상단에 정렬되도록 UX 최적화.
- 카드 ID가 비어 있으면 “미지정” 상태로 저장해 기존 UX 유지.
- UX 권장안: **설정 화면**에서 카드 풀을 미리 관리하되, **컨텍스트 모달**로도 등록할 수 있게 하여 흐름을 끊지 않음. 단순한 “옵션” 스위치에 카드 정보를 묻는 것보다 입력 목적이 명확하고 재사용성이 높음.
## 홈 화면 및 리스트 UI
- `HomeContent`를 상태형으로 전환하고 `enum SubscriptionGrouping { category, paymentCard }`를 유지. 선택 상태는 `SharedPreferences` 등으로 로컬 저장.
- “내 구독” 헤더 오른쪽에 SegmentedButton/ChoiceChip으로 카테고리↔카드 토글을 제공.
- `SubscriptionListWidget`을 범용 그룹 리스트로 확장:
- 그룹 메타데이터(타이틀, 통화 합계, 색상, 서브텍스트)를 받아 헤더 구성.
- 카드 모드에서는 회사명 + `****1234`, 카드 색상 배지, 카드별 통화 합계를 노출.
- 개별 구독 카드(`SubscriptionCard`) 상단에 결제수단 Chip을 추가해 어떤 카드에 속했는지 즉시 파악 가능.
## 구독 상세 화면 반영
- `DetailScreen` 상단 요약 카드에 결제수단 Chip/배지와 카드 색상을 노출.
- “결제 정보” 섹션에 “결제수단” 행을 추가해 회사명 + `****1234`, 카드별 메모 등을 보여줌.
- 상세 화면의 편집 아이콘 → 편집 시트로 진입 시 현재 `paymentCardId`를 기본 선택하여 사용자가 쉽게 변경할 수 있게 함.
- 카드 Chip을 탭하면 카드 관리 화면으로 이동하거나 빠른 편집 시트를 띄워 카드 명칭/색상 수정이 가능하도록 연동.
## SMS 스캔 흐름 적용
- `SmsScanController`가 생성한 임시 구독 모델에도 `paymentCardId` 필드를 포함.
- 스캔 결과 리뷰 리스트에서 각 구독 옆에 카드 선택 드롭다운을 노출:
- 기본값은 (1) 동일 발급사를 과거에 사용한 기록이 있으면 해당 카드, (2) 지정된 기본 카드, (3) “미지정” 순으로 결정.
- 다중 선택을 빠르게 하기 위해 스와이프/컨텍스트 메뉴 대신 인라인 세그먼트나 바텀 시트를 사용.
- “모두 저장” 시 선택된 카드 ID를 `SubscriptionProvider.addSubscription` 호출에 전달.
- SMS 패턴으로 카드사를 추정할 수 있는 경우(문구에 “KB국민카드 ****1234” 등)라면 자동으로 새 카드 템플릿을 제안하고, 사용자 확인 후 생성하도록 선택지를 제공.
## 화면/플로우별 변경 영향 (릴리스 전 점검)
### 홈/목록/위젯
- `HomeContent`, `SubscriptionListWidget`, `CategoryHeaderWidget`, `SubscriptionCard`, `NativeAdWidget` 인접 간격 등 모든 위젯이 새로운 그룹 메타데이터를 받아도 레이아웃이 깨지지 않는지 확인.
- 카드 모드에서 스켈레톤/EmptyState/애니메이션이 그대로 작동하는지, 그리고 `RefreshIndicator`·무한 스크롤이 정상인지 검증.
- 다국어(`en/ko/ja/zh`)에서 카드명/`****1234` 조합이 줄바꿈되지 않도록 최소/최대 길이 처리.
### 구독 추가/편집/상세
- `AddSubscriptionController`, `DetailScreenController`의 상태/검증 로직에 `paymentCardId`가 포함되었는지 확인.
- 저장/취소/변경 이벤트에서 카드 ID가 누락될 경우 기본값 처리.
- 이벤트/할인 섹션, URL 섹션 등 기존 위젯과 상호작용 시 포커스 이동·폼 검증이 동일하게 작동하는지 QA.
- 상세 화면 헤더/폼/아코디언 등 모든 서브 위젯(`detail_*`)이 카드 배지를 수용하도록 패딩 보정.
### SMS 스캔 및 자동 감지
- `SmsScanController`, `SmsScanner`, `SubscriptionConverter` 등 데이터 파이프라인에 카드 메타 추가.
- 스캔 결과 UI(선택 리스트, 확정 다이얼로그, Snackbar)에서 카드가 선택되지 않았을 때 경고/기본값 표시를 명확히 함.
- 자동 감지 카드 생성 로직은 사용자 최종 확인 후만 저장되도록 하고, 잘못된 카드 추론 시 수정 경로를 안내.
### 분석/대시보드
- `AnalysisScreen`, `SubscriptionPieChartCard`, `TotalExpenseSummaryCard`, `MonthlyExpenseChartCard`, `EventAnalysisCard`가 카드 모드 전환에 따른 필터/데이터세트 변경을 감지하는지 확인.
- 향후 카드별 하이라이트를 추가할 경우를 대비해 `SubscriptionGroupingHelper` 출력 구조가 확장 가능한지 검토.
### 설정/관리/내비게이션
- `SettingsScreen` 내 새 “결제수단 관리” 항목 및 `PaymentCardManagementScreen`이 탐색 스택/앱 잠금 흐름에 맞게 라우팅되는지 확인.
- `NavigationProvider``FloatingNavigationBar` 상태와 충돌하지 않는지 QA.
### 데이터/싱크/백업
- Hive 박스 버전이 증가한 뒤에도 기존 사용자 데이터(베타/QA) 로딩에 문제가 없는지 실제 마이그레이션 테스트.
- `SubscriptionProvider.refreshSubscriptions`, `notificationProvider`, `ExchangeRateService` 등 구독 컬렉션을 사용하는 모든 클래스에서 `paymentCardId`를 읽고 무시해도 예외가 발생하지 않는지 확인.
- 테스트 데이터(`lib/temp/test_sms_data.dart`, demo seed)에도 카드 필드가 포함되었는지 점검.
### 로컬라이제이션/접근성
- `AppLocalizations`, `intl` 메시지에 결제수단 관련 텍스트(“결제수단”, “카드 관리”, 오류 메시지 등)를 추가하고 4개 언어 번역을 준비.
- 스크린리더(VoiceOver/TalkBack)에서 카드 정보가 올바른 순서로 읽히는지, Chip 탭 시 라벨이 명확한지 확인.
- 컬러 배지 대비가 Material 3 접근성 가이드라인(대비 3:1 이상)을 만족하도록 색상 선택 UI/프리셋을 검토.
### QA 체크리스트
1. 새 카드 생성 → 구독 추가/편집/상세/SMS 스캔 → 삭제까지 전 과정에서 데이터 일관성 확인.
2. 카드 토글이 유지되는지(앱 재시작 포함) 확인.
3. `scripts/check.sh` + `flutter pub run build_runner build --delete-conflicting-outputs` 실행 후 경고 없는지 확인.
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`, 파이 차트, 이벤트 카드 등도 카드 필터를 쉽게 지원.
- 초기에는 홈 리스트에만 카드 모드를 적용하고, 이후 분석 탭에 “카드별 지출” 섹션을 추가해 순차 배포.
## 검증/운영
- 모든 변경 후 `scripts/check.sh`로 포맷(`dart format`), 정적 분석(`flutter analyze`), 테스트(`flutter test`)를 실행.
- Hive 스키마가 증가하므로 `flutter pub run build_runner build --delete-conflicting-outputs`를 통해 어댑터 재생성.
- UI 변경 시 기본/카드 모드 스크린샷을 확보해 QA 공유.
## 리스크 및 완화
- **Hive 마이그레이션**: 새 필드는 optional로 두고 기본값을 유지해 앱 크래시를 방지. 배포 전 베타 빌드로 데이터 검증.
- **사용자 혼란**: 토글 기본값을 기존 “카테고리”로 유지하고, 첫 진입 시 간단한 스낵바/tooltip으로 카드 뷰를 안내.
- **데이터 입력 번거로움**: 관리 화면에서 최소 필드만 요구하고, 구독 폼에서 바로 생성할 수 있게 동선 축소.

View File

@@ -4,6 +4,7 @@ import 'package:provider/provider.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import '../providers/subscription_provider.dart'; import '../providers/subscription_provider.dart';
import '../providers/category_provider.dart'; import '../providers/category_provider.dart';
import '../providers/payment_card_provider.dart';
import '../services/sms_service.dart'; import '../services/sms_service.dart';
import '../services/subscription_url_matcher.dart'; import '../services/subscription_url_matcher.dart';
import '../widgets/common/snackbar/app_snackbar.dart'; import '../widgets/common/snackbar/app_snackbar.dart';
@@ -31,6 +32,7 @@ class AddSubscriptionController {
DateTime? nextBillingDate; DateTime? nextBillingDate;
bool isLoading = false; bool isLoading = false;
String? selectedCategoryId; String? selectedCategoryId;
String? selectedPaymentCardId;
// Event State // Event State
bool isEventActive = false; bool isEventActive = false;
@@ -126,6 +128,13 @@ class AddSubscriptionController {
// Localizations가 아직 준비되지 않은 경우 기본값 유지 // Localizations가 아직 준비되지 않은 경우 기본값 유지
} }
// 기본 결제수단 설정
try {
final paymentCardProvider =
Provider.of<PaymentCardProvider>(context, listen: false);
selectedPaymentCardId = paymentCardProvider.defaultCard?.id;
} catch (_) {}
// 애니메이션 시작 // 애니메이션 시작
animationController!.forward(); animationController!.forward();
} }
@@ -503,6 +512,7 @@ class AddSubscriptionController {
nextBillingDate: adjustedNext, nextBillingDate: adjustedNext,
websiteUrl: websiteUrlController.text.trim(), websiteUrl: websiteUrlController.text.trim(),
categoryId: selectedCategoryId, categoryId: selectedCategoryId,
paymentCardId: selectedPaymentCardId,
currency: currency, currency: currency,
isEventActive: isEventActive, isEventActive: isEventActive,
eventStartDate: eventStartDate, eventStartDate: eventStartDate,

View File

@@ -34,6 +34,7 @@ class DetailScreenController extends ChangeNotifier {
late String _billingCycle; late String _billingCycle;
late DateTime _nextBillingDate; late DateTime _nextBillingDate;
String? _selectedCategoryId; String? _selectedCategoryId;
String? _selectedPaymentCardId;
late String _currency; late String _currency;
bool _isLoading = false; bool _isLoading = false;
@@ -46,6 +47,7 @@ class DetailScreenController extends ChangeNotifier {
String get billingCycle => _billingCycle; String get billingCycle => _billingCycle;
DateTime get nextBillingDate => _nextBillingDate; DateTime get nextBillingDate => _nextBillingDate;
String? get selectedCategoryId => _selectedCategoryId; String? get selectedCategoryId => _selectedCategoryId;
String? get selectedPaymentCardId => _selectedPaymentCardId;
String get currency => _currency; String get currency => _currency;
bool get isLoading => _isLoading; bool get isLoading => _isLoading;
bool get isEventActive => _isEventActive; 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) { set currency(String value) {
if (_currency != value) { if (_currency != value) {
_currency = value; _currency = value;
@@ -153,6 +162,7 @@ class DetailScreenController extends ChangeNotifier {
_billingCycle = subscription.billingCycle; _billingCycle = subscription.billingCycle;
_nextBillingDate = subscription.nextBillingDate; _nextBillingDate = subscription.nextBillingDate;
_selectedCategoryId = subscription.categoryId; _selectedCategoryId = subscription.categoryId;
_selectedPaymentCardId = subscription.paymentCardId;
_currency = subscription.currency; _currency = subscription.currency;
// Event State 초기화 // Event State 초기화
@@ -415,6 +425,7 @@ class DetailScreenController extends ChangeNotifier {
BillingDateUtil.ensureFutureDate(originalDateOnly, _billingCycle); BillingDateUtil.ensureFutureDate(originalDateOnly, _billingCycle);
subscription.nextBillingDate = adjustedNext; subscription.nextBillingDate = adjustedNext;
subscription.categoryId = _selectedCategoryId; subscription.categoryId = _selectedCategoryId;
subscription.paymentCardId = _selectedPaymentCardId;
subscription.currency = _currency; subscription.currency = _currency;
// 이벤트 정보 업데이트 // 이벤트 정보 업데이트

View File

@@ -1,8 +1,10 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../services/sms_scanner.dart'; import '../services/sms_scanner.dart';
import '../models/subscription.dart'; import '../models/subscription.dart';
import '../models/payment_card_suggestion.dart';
import '../services/sms_scan/subscription_converter.dart'; import '../services/sms_scan/subscription_converter.dart';
import '../services/sms_scan/subscription_filter.dart'; import '../services/sms_scan/subscription_filter.dart';
import '../services/sms_scan/sms_scan_result.dart';
import '../providers/subscription_provider.dart'; import '../providers/subscription_provider.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:flutter/foundation.dart' show kIsWeb; import 'package:flutter/foundation.dart' show kIsWeb;
@@ -11,6 +13,7 @@ import '../utils/logger.dart';
import '../providers/navigation_provider.dart'; import '../providers/navigation_provider.dart';
import '../providers/category_provider.dart'; import '../providers/category_provider.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
import '../providers/payment_card_provider.dart';
class SmsScanController extends ChangeNotifier { class SmsScanController extends ChangeNotifier {
// 상태 관리 // 상태 관리
@@ -22,22 +25,32 @@ class SmsScanController extends ChangeNotifier {
List<Subscription> _scannedSubscriptions = []; List<Subscription> _scannedSubscriptions = [];
List<Subscription> get scannedSubscriptions => _scannedSubscriptions; List<Subscription> get scannedSubscriptions => _scannedSubscriptions;
PaymentCardSuggestion? _currentSuggestion;
PaymentCardSuggestion? get currentSuggestion => _currentSuggestion;
bool _shouldSuggestCardCreation = false;
bool get shouldSuggestCardCreation => _shouldSuggestCardCreation;
int _currentIndex = 0; int _currentIndex = 0;
int get currentIndex => _currentIndex; int get currentIndex => _currentIndex;
String? _selectedCategoryId; String? _selectedCategoryId;
String? get selectedCategoryId => _selectedCategoryId; String? get selectedCategoryId => _selectedCategoryId;
String? _selectedPaymentCardId;
String? get selectedPaymentCardId => _selectedPaymentCardId;
final TextEditingController websiteUrlController = TextEditingController(); final TextEditingController websiteUrlController = TextEditingController();
final TextEditingController serviceNameController = TextEditingController();
// 의존성 // 의존성
final SmsScanner _smsScanner = SmsScanner(); final SmsScanner _smsScanner = SmsScanner();
final SubscriptionConverter _converter = SubscriptionConverter(); final SubscriptionConverter _converter = SubscriptionConverter();
final SubscriptionFilter _filter = SubscriptionFilter(); final SubscriptionFilter _filter = SubscriptionFilter();
bool _forceServiceNameEditing = false;
bool get isServiceNameEditable => _forceServiceNameEditing;
@override @override
void dispose() { void dispose() {
serviceNameController.dispose();
websiteUrlController.dispose(); websiteUrlController.dispose();
super.dispose(); super.dispose();
} }
@@ -47,8 +60,26 @@ class SmsScanController extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
void setSelectedPaymentCardId(String? paymentCardId) {
_selectedPaymentCardId = paymentCardId;
if (paymentCardId != null) {
_shouldSuggestCardCreation = false;
}
notifyListeners();
}
void resetWebsiteUrl() { void resetWebsiteUrl() {
websiteUrlController.text = ''; websiteUrlController.text = '';
serviceNameController.text = '';
}
void updateCurrentServiceName(String value) {
if (_currentIndex >= _scannedSubscriptions.length) return;
final trimmed = value.trim();
final updated = _scannedSubscriptions[_currentIndex]
.copyWith(serviceName: trimmed.isEmpty ? '알 수 없는 서비스' : trimmed);
_scannedSubscriptions[_currentIndex] = updated;
notifyListeners();
} }
Future<void> scanSms(BuildContext context) async { Future<void> scanSms(BuildContext context) async {
@@ -88,18 +119,18 @@ class SmsScanController extends ChangeNotifier {
// SMS 스캔 실행 // SMS 스캔 실행
Log.i('SMS 스캔 시작'); Log.i('SMS 스캔 시작');
final scannedSubscriptionModels = final List<SmsScanResult> scanResults =
await _smsScanner.scanForSubscriptions(); await _smsScanner.scanForSubscriptions();
Log.d('스캔된 구독: ${scannedSubscriptionModels.length}'); Log.d('스캔된 구독: ${scanResults.length}');
if (scannedSubscriptionModels.isNotEmpty) { if (scanResults.isNotEmpty) {
Log.d( Log.d(
'첫 번째 구독: ${scannedSubscriptionModels[0].serviceName}, 반복 횟수: ${scannedSubscriptionModels[0].repeatCount}'); '첫 번째 구독: ${scanResults[0].model.serviceName}, 반복 횟수: ${scanResults[0].model.repeatCount}');
} }
if (!context.mounted) return; if (!context.mounted) return;
if (scannedSubscriptionModels.isEmpty) { if (scanResults.isEmpty) {
Log.i('스캔된 구독이 없음'); Log.i('스캔된 구독이 없음');
_errorMessage = AppLocalizations.of(context).subscriptionNotFound; _errorMessage = AppLocalizations.of(context).subscriptionNotFound;
_isLoading = false; _isLoading = false;
@@ -109,7 +140,7 @@ class SmsScanController extends ChangeNotifier {
// SubscriptionModel을 Subscription으로 변환 // SubscriptionModel을 Subscription으로 변환
final scannedSubscriptions = final scannedSubscriptions =
_converter.convertModelsToSubscriptions(scannedSubscriptionModels); _converter.convertResultsToSubscriptions(scanResults);
// 2회 이상 반복 결제된 구독만 필터링 // 2회 이상 반복 결제된 구독만 필터링
final repeatSubscriptions = final repeatSubscriptions =
@@ -155,7 +186,9 @@ class SmsScanController extends ChangeNotifier {
_scannedSubscriptions = filteredSubscriptions; _scannedSubscriptions = filteredSubscriptions;
_isLoading = false; _isLoading = false;
websiteUrlController.text = ''; // URL 입력 필드 초기화 websiteUrlController.text = '';
_currentSuggestion = null;
_prepareCurrentSelection(context);
notifyListeners(); notifyListeners();
} catch (e) { } catch (e) {
Log.e('SMS 스캔 중 오류 발생', e); Log.e('SMS 스캔 중 오류 발생', e);
@@ -196,16 +229,23 @@ class SmsScanController extends ChangeNotifier {
if (_currentIndex >= _scannedSubscriptions.length) return; if (_currentIndex >= _scannedSubscriptions.length) return;
final subscription = _scannedSubscriptions[_currentIndex]; final subscription = _scannedSubscriptions[_currentIndex];
final inputName = serviceNameController.text.trim();
final resolvedServiceName =
inputName.isNotEmpty ? inputName : subscription.serviceName;
try { try {
final provider = final provider =
Provider.of<SubscriptionProvider>(context, listen: false); Provider.of<SubscriptionProvider>(context, listen: false);
final categoryProvider = final categoryProvider =
Provider.of<CategoryProvider>(context, listen: false); Provider.of<CategoryProvider>(context, listen: false);
final paymentCardProvider =
Provider.of<PaymentCardProvider>(context, listen: false);
final finalCategoryId = _selectedCategoryId ?? final finalCategoryId = _selectedCategoryId ??
subscription.category ?? subscription.category ??
getDefaultCategoryId(categoryProvider); getDefaultCategoryId(categoryProvider);
final finalPaymentCardId =
_selectedPaymentCardId ?? paymentCardProvider.defaultCard?.id;
// websiteUrl 처리 // websiteUrl 처리
final websiteUrl = websiteUrlController.text.trim().isNotEmpty final websiteUrl = websiteUrlController.text.trim().isNotEmpty
@@ -217,7 +257,7 @@ class SmsScanController extends ChangeNotifier {
// addSubscription 호출 // addSubscription 호출
await provider.addSubscription( await provider.addSubscription(
serviceName: subscription.serviceName, serviceName: resolvedServiceName,
monthlyCost: subscription.monthlyCost, monthlyCost: subscription.monthlyCost,
billingCycle: subscription.billingCycle, billingCycle: subscription.billingCycle,
nextBillingDate: subscription.nextBillingDate, nextBillingDate: subscription.nextBillingDate,
@@ -226,6 +266,7 @@ class SmsScanController extends ChangeNotifier {
repeatCount: subscription.repeatCount, repeatCount: subscription.repeatCount,
lastPaymentDate: subscription.lastPaymentDate, lastPaymentDate: subscription.lastPaymentDate,
categoryId: finalCategoryId, categoryId: finalCategoryId,
paymentCardId: finalPaymentCardId,
currency: subscription.currency, currency: subscription.currency,
); );
@@ -248,8 +289,11 @@ class SmsScanController extends ChangeNotifier {
void moveToNextSubscription(BuildContext context) { void moveToNextSubscription(BuildContext context) {
_currentIndex++; _currentIndex++;
websiteUrlController.text = ''; // URL 입력 필드 초기화 websiteUrlController.text = '';
_selectedCategoryId = null; // 카테고리 선택 초기화 serviceNameController.text = '';
_selectedCategoryId = null;
_forceServiceNameEditing = false;
_prepareCurrentSelection(context);
// 모든 구독을 처리했으면 홈 화면으로 이동 // 모든 구독을 처리했으면 홈 화면으로 이동
if (_currentIndex >= _scannedSubscriptions.length) { if (_currentIndex >= _scannedSubscriptions.length) {
@@ -270,6 +314,11 @@ class SmsScanController extends ChangeNotifier {
_scannedSubscriptions = []; _scannedSubscriptions = [];
_currentIndex = 0; _currentIndex = 0;
_errorMessage = null; _errorMessage = null;
_selectedPaymentCardId = null;
_currentSuggestion = null;
_shouldSuggestCardCreation = false;
serviceNameController.clear();
_forceServiceNameEditing = false;
notifyListeners(); notifyListeners();
} }
@@ -288,6 +337,100 @@ class SmsScanController extends ChangeNotifier {
if (websiteUrlController.text.isEmpty && currentSub.websiteUrl != null) { if (websiteUrlController.text.isEmpty && currentSub.websiteUrl != null) {
websiteUrlController.text = currentSub.websiteUrl!; websiteUrlController.text = currentSub.websiteUrl!;
} }
if (_shouldEnableServiceNameEditing(currentSub)) {
if (serviceNameController.text != currentSub.serviceName) {
serviceNameController.clear();
}
} else {
serviceNameController.text = currentSub.serviceName;
} }
} }
} }
String? _getDefaultPaymentCardId(BuildContext context) {
try {
final provider = Provider.of<PaymentCardProvider>(context, listen: false);
return provider.defaultCard?.id;
} catch (_) {
return null;
}
}
void _prepareCurrentSelection(BuildContext context) {
if (_currentIndex >= _scannedSubscriptions.length) {
_selectedPaymentCardId = null;
_currentSuggestion = null;
_forceServiceNameEditing = false;
serviceNameController.clear();
return;
}
final current = _scannedSubscriptions[_currentIndex];
_forceServiceNameEditing = _shouldEnableServiceNameEditing(current);
if (_forceServiceNameEditing && current.serviceName == '알 수 없는 서비스') {
serviceNameController.clear();
} else {
serviceNameController.text = current.serviceName;
}
// URL 기본값
if (current.websiteUrl != null && current.websiteUrl!.isNotEmpty) {
websiteUrlController.text = current.websiteUrl!;
} else {
websiteUrlController.clear();
}
_currentSuggestion = current.paymentCardSuggestion;
final matchedCardId = _matchCardWithSuggestion(context, _currentSuggestion);
_shouldSuggestCardCreation =
_currentSuggestion != null && matchedCardId == null;
if (matchedCardId != null) {
_selectedPaymentCardId = matchedCardId;
return;
}
// 모델에 직접 카드 정보가 존재하면 우선 사용
if (current.paymentCardId != null) {
_selectedPaymentCardId = current.paymentCardId;
return;
}
_selectedPaymentCardId = _getDefaultPaymentCardId(context);
}
String? _matchCardWithSuggestion(
BuildContext context, PaymentCardSuggestion? suggestion) {
if (suggestion == null) return null;
try {
final provider = Provider.of<PaymentCardProvider>(context, listen: false);
final cards = provider.cards;
if (cards.isEmpty) return null;
if (suggestion.hasLast4) {
for (final card in cards) {
if (card.last4 == suggestion.last4) {
return card.id;
}
}
}
final normalizedIssuer = suggestion.issuerName.toLowerCase();
for (final card in cards) {
final issuer = card.issuerName.toLowerCase();
if (issuer.contains(normalizedIssuer) ||
normalizedIssuer.contains(issuer)) {
return card.id;
}
}
} catch (_) {
return null;
}
return null;
}
bool _shouldEnableServiceNameEditing(Subscription subscription) {
final name = subscription.serviceName.trim();
return name.isEmpty || name == '알 수 없는 서비스';
}
}

View File

@@ -63,6 +63,56 @@ class AppLocalizations {
String get notifications => String get notifications =>
_localizedStrings['notifications'] ?? 'Notifications'; _localizedStrings['notifications'] ?? 'Notifications';
String get appLock => _localizedStrings['appLock'] ?? 'App Lock'; 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 권한 온보딩/설정 // SMS 권한 온보딩/설정
String get smsPermissionTitle => String get smsPermissionTitle =>
_localizedStrings['smsPermissionTitle'] ?? 'Request SMS Permission'; _localizedStrings['smsPermissionTitle'] ?? 'Request SMS Permission';
@@ -407,6 +457,13 @@ class AppLocalizations {
String get foundSubscription => String get foundSubscription =>
_localizedStrings['foundSubscription'] ?? 'Found subscription'; _localizedStrings['foundSubscription'] ?? 'Found subscription';
String get serviceName => _localizedStrings['serviceName'] ?? 'Service Name'; String get serviceName => _localizedStrings['serviceName'] ?? 'Service Name';
String get latestSmsMessage =>
_localizedStrings['latestSmsMessage'] ?? 'Latest SMS message';
String smsDetectedDate(String date) {
final template = _localizedStrings['smsDetectedDate'] ?? 'Detected on @';
return template.replaceAll('@', date);
}
String get nextBillingDateLabel => String get nextBillingDateLabel =>
_localizedStrings['nextBillingDateLabel'] ?? 'Next Billing Date'; _localizedStrings['nextBillingDateLabel'] ?? 'Next Billing Date';
String get category => _localizedStrings['category'] ?? 'Category'; String get category => _localizedStrings['category'] ?? 'Category';

View File

@@ -5,10 +5,12 @@ import 'package:flutter/foundation.dart' show kIsWeb, kDebugMode;
import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_localizations/flutter_localizations.dart';
import 'models/subscription_model.dart'; import 'models/subscription_model.dart';
import 'models/category_model.dart'; import 'models/category_model.dart';
import 'models/payment_card_model.dart';
import 'providers/subscription_provider.dart'; import 'providers/subscription_provider.dart';
import 'providers/app_lock_provider.dart'; import 'providers/app_lock_provider.dart';
import 'providers/notification_provider.dart'; import 'providers/notification_provider.dart';
import 'providers/navigation_provider.dart'; import 'providers/navigation_provider.dart';
import 'providers/payment_card_provider.dart';
import 'services/notification_service.dart'; import 'services/notification_service.dart';
import 'providers/category_provider.dart'; import 'providers/category_provider.dart';
import 'providers/locale_provider.dart'; import 'providers/locale_provider.dart';
@@ -69,14 +71,17 @@ Future<void> main() async {
await Hive.initFlutter(); await Hive.initFlutter();
Hive.registerAdapter(SubscriptionModelAdapter()); Hive.registerAdapter(SubscriptionModelAdapter());
Hive.registerAdapter(CategoryModelAdapter()); Hive.registerAdapter(CategoryModelAdapter());
Hive.registerAdapter(PaymentCardModelAdapter());
await Hive.openBox<SubscriptionModel>('subscriptions'); await Hive.openBox<SubscriptionModel>('subscriptions');
await Hive.openBox<CategoryModel>('categories'); await Hive.openBox<CategoryModel>('categories');
await Hive.openBox<PaymentCardModel>('payment_cards');
final appLockBox = await Hive.openBox<bool>('app_lock'); final appLockBox = await Hive.openBox<bool>('app_lock');
// 알림 서비스를 가장 먼저 초기화 // 알림 서비스를 가장 먼저 초기화
await NotificationService.init(); await NotificationService.init();
final subscriptionProvider = SubscriptionProvider(); final subscriptionProvider = SubscriptionProvider();
final categoryProvider = CategoryProvider(); final categoryProvider = CategoryProvider();
final paymentCardProvider = PaymentCardProvider();
final localeProvider = LocaleProvider(); final localeProvider = LocaleProvider();
final notificationProvider = NotificationProvider(); final notificationProvider = NotificationProvider();
final themeProvider = ThemeProvider(); final themeProvider = ThemeProvider();
@@ -84,6 +89,7 @@ Future<void> main() async {
await subscriptionProvider.init(); await subscriptionProvider.init();
await categoryProvider.init(); await categoryProvider.init();
await paymentCardProvider.init();
await localeProvider.init(); await localeProvider.init();
await notificationProvider.init(); await notificationProvider.init();
await themeProvider.initialize(); await themeProvider.initialize();
@@ -110,6 +116,7 @@ Future<void> main() async {
providers: [ providers: [
ChangeNotifierProvider(create: (_) => subscriptionProvider), ChangeNotifierProvider(create: (_) => subscriptionProvider),
ChangeNotifierProvider(create: (_) => categoryProvider), ChangeNotifierProvider(create: (_) => categoryProvider),
ChangeNotifierProvider(create: (_) => paymentCardProvider),
ChangeNotifierProvider(create: (_) => AppLockProvider(appLockBox)), ChangeNotifierProvider(create: (_) => AppLockProvider(appLockBox)),
ChangeNotifierProvider(create: (_) => notificationProvider), ChangeNotifierProvider(create: (_) => notificationProvider),
ChangeNotifierProvider(create: (_) => localeProvider), ChangeNotifierProvider(create: (_) => localeProvider),

View File

@@ -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,
});
}

View File

@@ -0,0 +1,56 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'payment_card_model.dart';
// **************************************************************************
// TypeAdapterGenerator
// **************************************************************************
class PaymentCardModelAdapter extends TypeAdapter<PaymentCardModel> {
@override
final int typeId = 2;
@override
PaymentCardModel read(BinaryReader reader) {
final numOfFields = reader.readByte();
final fields = <int, dynamic>{
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;
}

View File

@@ -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;
}

View File

@@ -1,3 +1,5 @@
import 'payment_card_suggestion.dart';
class Subscription { class Subscription {
final String id; final String id;
final String serviceName; final String serviceName;
@@ -10,6 +12,9 @@ class Subscription {
final DateTime? lastPaymentDate; final DateTime? lastPaymentDate;
final String? websiteUrl; final String? websiteUrl;
final String currency; final String currency;
final String? paymentCardId;
final PaymentCardSuggestion? paymentCardSuggestion;
final String? rawMessage;
Subscription({ Subscription({
required this.id, required this.id,
@@ -23,8 +28,52 @@ class Subscription {
this.lastPaymentDate, this.lastPaymentDate,
this.websiteUrl, this.websiteUrl,
this.currency = 'KRW', this.currency = 'KRW',
this.paymentCardId,
this.paymentCardSuggestion,
this.rawMessage,
}); });
Subscription copyWith({
String? id,
String? serviceName,
double? monthlyCost,
String? billingCycle,
DateTime? nextBillingDate,
String? category,
String? notes,
int? repeatCount,
DateTime? lastPaymentDate,
String? websiteUrl,
String? currency,
String? paymentCardId,
PaymentCardSuggestion? paymentCardSuggestion,
String? rawMessage,
}) {
return Subscription(
id: id ?? this.id,
serviceName: serviceName ?? this.serviceName,
monthlyCost: monthlyCost ?? this.monthlyCost,
billingCycle: billingCycle ?? this.billingCycle,
nextBillingDate: nextBillingDate ?? this.nextBillingDate,
category: category ?? this.category,
notes: notes ?? this.notes,
repeatCount: repeatCount ?? this.repeatCount,
lastPaymentDate: lastPaymentDate ?? this.lastPaymentDate,
websiteUrl: websiteUrl ?? this.websiteUrl,
currency: currency ?? this.currency,
paymentCardId: paymentCardId ?? this.paymentCardId,
paymentCardSuggestion: paymentCardSuggestion ??
(this.paymentCardSuggestion != null
? PaymentCardSuggestion(
issuerName: this.paymentCardSuggestion!.issuerName,
last4: this.paymentCardSuggestion!.last4,
source: this.paymentCardSuggestion!.source,
)
: null),
rawMessage: rawMessage ?? this.rawMessage,
);
}
Map<String, dynamic> toMap() { Map<String, dynamic> toMap() {
return { return {
'id': id, 'id': id,
@@ -38,6 +87,11 @@ class Subscription {
'lastPaymentDate': lastPaymentDate?.toIso8601String(), 'lastPaymentDate': lastPaymentDate?.toIso8601String(),
'websiteUrl': websiteUrl, 'websiteUrl': websiteUrl,
'currency': currency, 'currency': currency,
'paymentCardId': paymentCardId,
'paymentCardSuggestionIssuer': paymentCardSuggestion?.issuerName,
'paymentCardSuggestionLast4': paymentCardSuggestion?.last4,
'paymentCardSuggestionSource': paymentCardSuggestion?.source,
'rawMessage': rawMessage,
}; };
} }
@@ -56,6 +110,15 @@ class Subscription {
: null, : null,
websiteUrl: map['websiteUrl'] as String?, websiteUrl: map['websiteUrl'] as String?,
currency: map['currency'] as String? ?? 'KRW', 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,
rawMessage: map['rawMessage'] as String?,
); );
} }

View File

@@ -49,6 +49,9 @@ class SubscriptionModel extends HiveObject {
@HiveField(14) @HiveField(14)
double? eventPrice; // 이벤트 기간 중 가격 double? eventPrice; // 이벤트 기간 중 가격
@HiveField(15)
String? paymentCardId; // 연결된 결제수단의 ID
SubscriptionModel({ SubscriptionModel({
required this.id, required this.id,
required this.serviceName, required this.serviceName,
@@ -65,6 +68,7 @@ class SubscriptionModel extends HiveObject {
this.eventStartDate, this.eventStartDate,
this.eventEndDate, this.eventEndDate,
this.eventPrice, this.eventPrice,
this.paymentCardId,
}); });
// 주기적 결제 여부 확인 // 주기적 결제 여부 확인

View File

@@ -32,13 +32,14 @@ class SubscriptionModelAdapter extends TypeAdapter<SubscriptionModel> {
eventStartDate: fields[12] as DateTime?, eventStartDate: fields[12] as DateTime?,
eventEndDate: fields[13] as DateTime?, eventEndDate: fields[13] as DateTime?,
eventPrice: fields[14] as double?, eventPrice: fields[14] as double?,
paymentCardId: fields[15] as String?,
); );
} }
@override @override
void write(BinaryWriter writer, SubscriptionModel obj) { void write(BinaryWriter writer, SubscriptionModel obj) {
writer writer
..writeByte(15) ..writeByte(16)
..writeByte(0) ..writeByte(0)
..write(obj.id) ..write(obj.id)
..writeByte(1) ..writeByte(1)
@@ -68,7 +69,9 @@ class SubscriptionModelAdapter extends TypeAdapter<SubscriptionModel> {
..writeByte(13) ..writeByte(13)
..write(obj.eventEndDate) ..write(obj.eventEndDate)
..writeByte(14) ..writeByte(14)
..write(obj.eventPrice); ..write(obj.eventPrice)
..writeByte(15)
..write(obj.paymentCardId);
} }
@override @override

View File

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

View File

@@ -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<PaymentCardModel> _cardBox;
final List<PaymentCardModel> _cards = [];
List<PaymentCardModel> get cards => List.unmodifiable(_cards);
PaymentCardModel? get defaultCard {
try {
return _cards.firstWhere((card) => card.isDefault);
} catch (_) {
return _cards.isNotEmpty ? _cards.first : null;
}
}
Future<void> init() async {
_cardBox = await Hive.openBox<PaymentCardModel>('payment_cards');
_cards
..clear()
..addAll(_cardBox.values);
_sortCards();
notifyListeners();
}
Future<PaymentCardModel> 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<void> 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<void> 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<void> 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<void> _unsetDefaultCard({String? exceptId}) async {
for (final card in _cards) {
if (card.isDefault && card.id != exceptId) {
card.isDefault = false;
await _cardBox.put(card.id, card);
}
}
}
}

View File

@@ -118,6 +118,7 @@ class SubscriptionProvider extends ChangeNotifier {
required DateTime nextBillingDate, required DateTime nextBillingDate,
String? websiteUrl, String? websiteUrl,
String? categoryId, String? categoryId,
String? paymentCardId,
bool isAutoDetected = false, bool isAutoDetected = false,
int repeatCount = 1, int repeatCount = 1,
DateTime? lastPaymentDate, DateTime? lastPaymentDate,
@@ -136,6 +137,7 @@ class SubscriptionProvider extends ChangeNotifier {
nextBillingDate: nextBillingDate, nextBillingDate: nextBillingDate,
websiteUrl: websiteUrl, websiteUrl: websiteUrl,
categoryId: categoryId, categoryId: categoryId,
paymentCardId: paymentCardId,
isAutoDetected: isAutoDetected, isAutoDetected: isAutoDetected,
repeatCount: repeatCount, repeatCount: repeatCount,
lastPaymentDate: lastPaymentDate, lastPaymentDate: lastPaymentDate,
@@ -268,17 +270,22 @@ class SubscriptionProvider extends ChangeNotifier {
} }
/// 총 월간 지출을 계산합니다. (로케일별 기본 통화로 환산) /// 총 월간 지출을 계산합니다. (로케일별 기본 통화로 환산)
Future<double> calculateTotalExpense({String? locale}) async { Future<double> calculateTotalExpense({
if (_subscriptions.isEmpty) return 0.0; String? locale,
List<SubscriptionModel>? subset,
}) async {
final targetSubscriptions = subset ?? _subscriptions;
if (targetSubscriptions.isEmpty) return 0.0;
// locale이 제공되지 않으면 현재 로케일 사용 // locale이 제공되지 않으면 현재 로케일 사용
final targetCurrency = final targetCurrency =
locale != null ? CurrencyUtil.getDefaultCurrency(locale) : 'KRW'; // 기본값 locale != null ? CurrencyUtil.getDefaultCurrency(locale) : 'KRW'; // 기본값
debugPrint('[calculateTotalExpense] 계산 시작 - 타겟 통화: $targetCurrency'); debugPrint('[calculateTotalExpense] 계산 시작 - 타겟 통화: $targetCurrency, '
'대상 구독: ${targetSubscriptions.length}');
double total = 0.0; double total = 0.0;
for (final subscription in _subscriptions) { for (final subscription in targetSubscriptions) {
final currentPrice = subscription.currentPrice; final currentPrice = subscription.currentPrice;
debugPrint('[calculateTotalExpense] ${subscription.serviceName}: ' debugPrint('[calculateTotalExpense] ${subscription.serviceName}: '
'$currentPrice ${subscription.currency} (이벤트 적용: ${subscription.isCurrentlyInEvent})'); '$currentPrice ${subscription.currency} (이벤트 적용: ${subscription.isCurrentlyInEvent})');
@@ -292,15 +299,19 @@ class SubscriptionProvider extends ChangeNotifier {
total += converted ?? currentPrice; total += converted ?? currentPrice;
} }
debugPrint('[calculateTotalExpense] 총 지출 계산 완료: $total $targetCurrency'); debugPrint('[calculateTotalExpense] 총 지출 계산 완료: $total '
'$targetCurrency (대상 ${targetSubscriptions.length}개)');
return total; return total;
} }
/// 최근 6개월의 월별 지출 데이터를 반환합니다. (로케일별 기본 통화로 환산) /// 최근 6개월의 월별 지출 데이터를 반환합니다. (로케일별 기본 통화로 환산)
Future<List<Map<String, dynamic>>> getMonthlyExpenseData( Future<List<Map<String, dynamic>>> getMonthlyExpenseData({
{String? locale}) async { String? locale,
List<SubscriptionModel>? subset,
}) async {
final now = DateTime.now(); final now = DateTime.now();
final List<Map<String, dynamic>> monthlyData = []; final List<Map<String, dynamic>> monthlyData = [];
final targetSubscriptions = subset ?? _subscriptions;
// locale이 제공되지 않으면 현재 로케일 사용 // locale이 제공되지 않으면 현재 로케일 사용
final targetCurrency = final targetCurrency =
@@ -321,7 +332,7 @@ class SubscriptionProvider extends ChangeNotifier {
} }
// 해당 월에 활성화된 구독 계산 // 해당 월에 활성화된 구독 계산
for (final subscription in _subscriptions) { for (final subscription in targetSubscriptions) {
if (isCurrentMonth) { if (isCurrentMonth) {
// 현재 월인 경우: 모든 활성 구독 포함 (calculateTotalExpense와 동일하게) // 현재 월인 경우: 모든 활성 구독 포함 (calculateTotalExpense와 동일하게)
final cost = subscription.currentPrice; final cost = subscription.currentPrice;

View File

@@ -8,6 +8,7 @@ import 'package:submanager/screens/settings_screen.dart';
import 'package:submanager/screens/splash_screen.dart'; import 'package:submanager/screens/splash_screen.dart';
import 'package:submanager/screens/sms_permission_screen.dart'; import 'package:submanager/screens/sms_permission_screen.dart';
import 'package:submanager/models/subscription_model.dart'; import 'package:submanager/models/subscription_model.dart';
import 'package:submanager/screens/payment_card_management_screen.dart';
class AppRoutes { class AppRoutes {
static const String splash = '/splash'; static const String splash = '/splash';
@@ -18,6 +19,7 @@ class AppRoutes {
static const String analysis = '/analysis'; static const String analysis = '/analysis';
static const String settings = '/settings'; static const String settings = '/settings';
static const String smsPermission = '/sms-permission'; static const String smsPermission = '/sms-permission';
static const String paymentCardManagement = '/payment-card-management';
static Map<String, WidgetBuilder> getRoutes() { static Map<String, WidgetBuilder> getRoutes() {
return { return {
@@ -28,6 +30,7 @@ class AppRoutes {
analysis: (context) => const AnalysisScreen(), analysis: (context) => const AnalysisScreen(),
settings: (context) => const SettingsScreen(), settings: (context) => const SettingsScreen(),
smsPermission: (context) => const SmsPermissionScreen(), smsPermission: (context) => const SmsPermissionScreen(),
paymentCardManagement: (context) => const PaymentCardManagementScreen(),
}; };
} }
@@ -61,6 +64,8 @@ class AppRoutes {
case smsPermission: case smsPermission:
return _buildRoute(const SmsPermissionScreen(), routeSettings); return _buildRoute(const SmsPermissionScreen(), routeSettings);
case paymentCardManagement:
return _buildRoute(const PaymentCardManagementScreen(), routeSettings);
default: default:
return _errorRoute(); return _errorRoute();

View File

@@ -1,7 +1,12 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.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/subscription_provider.dart';
import '../providers/locale_provider.dart'; import '../providers/locale_provider.dart';
import '../utils/payment_card_utils.dart';
import '../widgets/native_ad_widget.dart'; import '../widgets/native_ad_widget.dart';
import '../widgets/analysis/analysis_screen_spacer.dart'; import '../widgets/analysis/analysis_screen_spacer.dart';
import '../widgets/analysis/subscription_pie_chart_card.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/monthly_expense_chart_card.dart';
import '../widgets/analysis/event_analysis_card.dart'; import '../widgets/analysis/event_analysis_card.dart';
enum AnalysisCardFilterType { all, unassigned, card }
class AnalysisScreen extends StatefulWidget { class AnalysisScreen extends StatefulWidget {
const AnalysisScreen({super.key}); const AnalysisScreen({super.key});
@@ -25,6 +32,8 @@ class _AnalysisScreenState extends State<AnalysisScreen>
List<Map<String, dynamic>> _monthlyData = []; List<Map<String, dynamic>> _monthlyData = [];
bool _isLoading = true; bool _isLoading = true;
String _lastDataHash = ''; String _lastDataHash = '';
AnalysisCardFilterType _filterType = AnalysisCardFilterType.all;
String? _selectedCardId;
@override @override
void initState() { void initState() {
@@ -42,7 +51,8 @@ class _AnalysisScreenState extends State<AnalysisScreen>
super.didChangeDependencies(); super.didChangeDependencies();
// Provider 변경 감지 // Provider 변경 감지
final provider = Provider.of<SubscriptionProvider>(context); final provider = Provider.of<SubscriptionProvider>(context);
final currentHash = _calculateDataHash(provider); final filtered = _filterSubscriptions(provider.subscriptions);
final currentHash = _calculateDataHash(provider, filtered: filtered);
debugPrint('[AnalysisScreen] didChangeDependencies: ' debugPrint('[AnalysisScreen] didChangeDependencies: '
'현재 해시=$currentHash, 이전 해시=$_lastDataHash, 로딩중=$_isLoading'); '현재 해시=$currentHash, 이전 해시=$_lastDataHash, 로딩중=$_isLoading');
@@ -64,13 +74,16 @@ class _AnalysisScreenState extends State<AnalysisScreen>
} }
/// 구독 데이터의 해시값을 계산하여 변경 감지 /// 구독 데이터의 해시값을 계산하여 변경 감지
String _calculateDataHash(SubscriptionProvider provider) { String _calculateDataHash(
final subscriptions = provider.subscriptions; SubscriptionProvider provider, {
final buffer = StringBuffer(); List<SubscriptionModel>? filtered,
}) {
buffer.write(subscriptions.length); final subscriptions =
buffer.write('_'); filtered ?? _filterSubscriptions(provider.subscriptions);
buffer.write(provider.totalMonthlyExpense.toStringAsFixed(2)); final buffer = StringBuffer()
..write(_filterType.name)
..write('_${_selectedCardId ?? 'all'}')
..write('_${subscriptions.length}');
for (final sub in subscriptions) { for (final sub in subscriptions) {
buffer.write( buffer.write(
@@ -80,6 +93,38 @@ class _AnalysisScreenState extends State<AnalysisScreen>
return buffer.toString(); return buffer.toString();
} }
List<SubscriptionModel> _filterSubscriptions(
List<SubscriptionModel> 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<void> _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<void> _loadData() async { Future<void> _loadData() async {
debugPrint('[AnalysisScreen] _loadData 호출됨'); debugPrint('[AnalysisScreen] _loadData 호출됨');
setState(() { setState(() {
@@ -89,17 +134,25 @@ class _AnalysisScreenState extends State<AnalysisScreen>
final provider = Provider.of<SubscriptionProvider>(context, listen: false); final provider = Provider.of<SubscriptionProvider>(context, listen: false);
final localeProvider = Provider.of<LocaleProvider>(context, listen: false); final localeProvider = Provider.of<LocaleProvider>(context, listen: false);
final locale = localeProvider.locale.languageCode; 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'); debugPrint('[AnalysisScreen] 총 지출 계산 완료: $_totalExpense');
// 월별 데이터 계산 (로케일별 기본 통화로 환산) // 월별 데이터 계산 (로케일별 기본 통화로 환산)
_monthlyData = await provider.getMonthlyExpenseData(locale: locale); _monthlyData = await provider.getMonthlyExpenseData(
locale: locale,
subset: filteredSubscriptions,
);
debugPrint('[AnalysisScreen] 월별 데이터 계산 완료: ${_monthlyData.length}개월'); debugPrint('[AnalysisScreen] 월별 데이터 계산 완료: ${_monthlyData.length}개월');
// 현재 데이터 해시값 저장 // 현재 데이터 해시값 저장
_lastDataHash = _calculateDataHash(provider); _lastDataHash =
_calculateDataHash(provider, filtered: filteredSubscriptions);
debugPrint('[AnalysisScreen] 데이터 해시값 저장: $_lastDataHash'); debugPrint('[AnalysisScreen] 데이터 해시값 저장: $_lastDataHash');
setState(() { setState(() {
@@ -130,6 +183,128 @@ class _AnalysisScreenState extends State<AnalysisScreen>
); );
} }
Widget _buildCardFilterSection(
BuildContext context, PaymentCardProvider cardProvider) {
final loc = AppLocalizations.of(context);
final chips = <Widget>[
_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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// Provider를 직접 사용하여 변경 감지 // Provider를 직접 사용하여 변경 감지
@@ -142,6 +317,9 @@ class _AnalysisScreenState extends State<AnalysisScreen>
); );
} }
final cardProvider = Provider.of<PaymentCardProvider>(context);
final filteredSubscriptions = _filterSubscriptions(subscriptions);
return CustomScrollView( return CustomScrollView(
controller: _scrollController, controller: _scrollController,
physics: const BouncingScrollPhysics(), physics: const BouncingScrollPhysics(),
@@ -159,9 +337,13 @@ class _AnalysisScreenState extends State<AnalysisScreen>
const AnalysisScreenSpacer(), const AnalysisScreenSpacer(),
_buildCardFilterSection(context, cardProvider),
const AnalysisScreenSpacer(),
// 1. 구독 비율 파이 차트 // 1. 구독 비율 파이 차트
SubscriptionPieChartCard( SubscriptionPieChartCard(
subscriptions: subscriptions, subscriptions: filteredSubscriptions,
animationController: _animationController, animationController: _animationController,
), ),
@@ -170,7 +352,7 @@ class _AnalysisScreenState extends State<AnalysisScreen>
// 2. 총 지출 요약 카드 // 2. 총 지출 요약 카드
TotalExpenseSummaryCard( TotalExpenseSummaryCard(
key: ValueKey('total_expense_$_lastDataHash'), key: ValueKey('total_expense_$_lastDataHash'),
subscriptions: subscriptions, subscriptions: filteredSubscriptions,
totalExpense: _totalExpense, totalExpense: _totalExpense,
animationController: _animationController, animationController: _animationController,
), ),
@@ -189,6 +371,7 @@ class _AnalysisScreenState extends State<AnalysisScreen>
// 4. 이벤트 분석 // 4. 이벤트 분석
EventAnalysisCard( EventAnalysisCard(
animationController: _animationController, animationController: _animationController,
subscriptions: filteredSubscriptions,
), ),
// FloatingNavigationBar를 위한 충분한 하단 여백 // FloatingNavigationBar를 위한 충분한 하단 여백

View File

@@ -3,6 +3,7 @@ import 'package:provider/provider.dart';
import '../models/subscription_model.dart'; import '../models/subscription_model.dart';
import '../controllers/detail_screen_controller.dart'; import '../controllers/detail_screen_controller.dart';
import '../widgets/detail/detail_header_section.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_form_section.dart';
import '../widgets/detail/detail_event_section.dart'; import '../widgets/detail/detail_event_section.dart';
import '../widgets/detail/detail_url_section.dart'; import '../widgets/detail/detail_url_section.dart';
@@ -120,6 +121,13 @@ class _DetailScreenState extends State<DetailScreen>
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
DetailPaymentInfoSection(
controller: _controller,
fadeAnimation: _controller.fadeAnimation!,
slideAnimation: _controller.slideAnimation!,
),
const SizedBox(height: 16),
// 기본 정보 폼 섹션 // 기본 정보 폼 섹션
DetailFormSection( DetailFormSection(
controller: _controller, controller: _controller,

View File

@@ -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<void> _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<PaymentCardProvider>(
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<String>(
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<bool>(
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;
}
}
}

View File

@@ -17,6 +17,7 @@ import '../providers/theme_provider.dart';
import '../theme/adaptive_theme.dart'; import '../theme/adaptive_theme.dart';
import '../widgets/common/layout/page_container.dart'; import '../widgets/common/layout/page_container.dart';
import '../theme/color_scheme_ext.dart'; import '../theme/color_scheme_ext.dart';
import '../widgets/app_navigator.dart';
class SettingsScreen extends StatelessWidget { class SettingsScreen extends StatelessWidget {
const SettingsScreen({super.key}); const SettingsScreen({super.key});
@@ -79,6 +80,7 @@ class SettingsScreen extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final loc = AppLocalizations.of(context);
return Column( return Column(
children: [ children: [
Expanded( Expanded(
@@ -99,6 +101,48 @@ class SettingsScreen extends StatelessWidget {
const SizedBox(height: 16), 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( Card(
margin: margin:
const EdgeInsets.symmetric(vertical: 8, horizontal: 16), const EdgeInsets.symmetric(vertical: 8, horizontal: 16),

View File

@@ -6,6 +6,9 @@ import '../widgets/sms_scan/scan_progress_widget.dart';
import '../widgets/sms_scan/subscription_card_widget.dart'; import '../widgets/sms_scan/subscription_card_widget.dart';
import '../widgets/common/snackbar/app_snackbar.dart'; import '../widgets/common/snackbar/app_snackbar.dart';
import '../l10n/app_localizations.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 { class SmsScanScreen extends StatefulWidget {
const SmsScanScreen({super.key}); const SmsScanScreen({super.key});
@@ -93,11 +96,31 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
const SizedBox(height: 24), const SizedBox(height: 24),
SubscriptionCardWidget( SubscriptionCardWidget(
subscription: currentSubscription, subscription: currentSubscription,
serviceNameController: _controller.serviceNameController,
websiteUrlController: _controller.websiteUrlController, websiteUrlController: _controller.websiteUrlController,
selectedCategoryId: _controller.selectedCategoryId, selectedCategoryId: _controller.selectedCategoryId,
onCategoryChanged: _controller.setSelectedCategoryId, onCategoryChanged: _controller.setSelectedCategoryId,
selectedPaymentCardId: _controller.selectedPaymentCardId,
onPaymentCardChanged: _controller.setSelectedPaymentCardId,
enableServiceNameEditing: _controller.isServiceNameEditable,
onServiceNameChanged: _controller.isServiceNameEditable
? _controller.updateCurrentServiceName
: null,
onAddCard: () async {
final newCardId = await PaymentCardFormSheet.show(context);
if (newCardId != null) {
_controller.setSelectedPaymentCardId(newCardId);
}
},
onManageCards: () {
Navigator.of(context).pushNamed(AppRoutes.paymentCardManagement);
},
onAdd: _handleAddSubscription, onAdd: _handleAddSubscription,
onSkip: _handleSkipSubscription, onSkip: _handleSkipSubscription,
detectedCardSuggestion: _controller.currentSuggestion,
showDetectedCardShortcut: _controller.shouldSuggestCardCreation,
onAddDetectedCard: (suggestion) =>
_handleDetectedCardCreation(suggestion),
), ),
], ],
); );
@@ -114,6 +137,18 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
WidgetsBinding.instance.addPostFrameCallback((_) => _scrollToTop()); WidgetsBinding.instance.addPostFrameCallback((_) => _scrollToTop());
} }
Future<void> _handleDetectedCardCreation(
PaymentCardSuggestion suggestion) async {
final newCardId = await PaymentCardFormSheet.show(
context,
initialIssuerName: suggestion.issuerName,
initialLast4: suggestion.last4,
);
if (newCardId != null) {
_controller.setSelectedPaymentCardId(newCardId);
}
}
void _scrollToTop() { void _scrollToTop() {
if (!_scrollController.hasClients) return; if (!_scrollController.hasClients) return;
_scrollController.animateTo( _scrollController.animateTo(

View File

@@ -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,
});
}

View File

@@ -1,21 +1,21 @@
import '../../models/subscription.dart'; import '../../models/subscription.dart';
import '../../models/subscription_model.dart'; import 'sms_scan_result.dart';
class SubscriptionConverter { class SubscriptionConverter {
// SubscriptionModel 리스트를 Subscription 리스트로 변환 // SubscriptionModel 리스트를 Subscription 리스트로 변환
List<Subscription> convertModelsToSubscriptions( List<Subscription> convertResultsToSubscriptions(
List<SubscriptionModel> models) { List<SmsScanResult> results) {
final result = <Subscription>[]; final result = <Subscription>[];
for (var model in models) { for (final smsResult in results) {
try { try {
final subscription = _convertSingle(model); final subscription = _convertSingle(smsResult);
result.add(subscription); result.add(subscription);
// 개발 편의를 위한 디버그 로그 // 개발 편의를 위한 디버그 로그
// ignore: avoid_print // ignore: avoid_print
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) { } catch (e) {
// ignore: avoid_print // ignore: avoid_print
print('모델 변환 중 오류 발생: $e'); print('모델 변환 중 오류 발생: $e');
@@ -26,7 +26,8 @@ class SubscriptionConverter {
} }
// 단일 모델 변환 // 단일 모델 변환
Subscription _convertSingle(SubscriptionModel model) { Subscription _convertSingle(SmsScanResult result) {
final model = result.model;
return Subscription( return Subscription(
id: model.id, id: model.id,
serviceName: model.serviceName, serviceName: model.serviceName,
@@ -38,6 +39,9 @@ class SubscriptionConverter {
lastPaymentDate: model.lastPaymentDate, lastPaymentDate: model.lastPaymentDate,
websiteUrl: model.websiteUrl, websiteUrl: model.websiteUrl,
currency: model.currency, currency: model.currency,
paymentCardId: model.paymentCardId,
paymentCardSuggestion: result.cardSuggestion,
rawMessage: result.rawMessage,
); );
} }

View File

@@ -1,3 +1,5 @@
import 'dart:math' as math;
import 'package:flutter/foundation.dart' show kIsWeb, compute; import 'package:flutter/foundation.dart' show kIsWeb, compute;
import 'package:flutter_sms_inbox/flutter_sms_inbox.dart'; import 'package:flutter_sms_inbox/flutter_sms_inbox.dart';
import '../models/subscription_model.dart'; import '../models/subscription_model.dart';
@@ -6,11 +8,13 @@ import '../temp/test_sms_data.dart';
import '../services/subscription_url_matcher.dart'; import '../services/subscription_url_matcher.dart';
import '../utils/platform_helper.dart'; import '../utils/platform_helper.dart';
import '../utils/business_day_util.dart'; import '../utils/business_day_util.dart';
import '../services/sms_scan/sms_scan_result.dart';
import '../models/payment_card_suggestion.dart';
class SmsScanner { class SmsScanner {
final SmsQuery _query = SmsQuery(); final SmsQuery _query = SmsQuery();
Future<List<SubscriptionModel>> scanForSubscriptions() async { Future<List<SmsScanResult>> scanForSubscriptions() async {
try { try {
List<dynamic> smsList; List<dynamic> smsList;
Log.d('SmsScanner: 스캔 시작'); Log.d('SmsScanner: 스캔 시작');
@@ -36,93 +40,42 @@ class SmsScanner {
return []; return [];
} }
final filteredSms = smsList
.whereType<Map<String, dynamic>>()
.where(_isEligibleSubscriptionSms)
.toList();
Log.d(
'SmsScanner: 유효 결제 SMS ${filteredSms.length}건 / 전체 ${smsList.length}');
if (filteredSms.isEmpty) {
Log.w('SmsScanner: 결제 패턴 SMS 미검출');
return [];
}
// SMS 데이터를 분석하여 반복 결제되는 구독 식별 // SMS 데이터를 분석하여 반복 결제되는 구독 식별
final List<SubscriptionModel> subscriptions = []; final List<SmsScanResult> subscriptions = [];
final Map<String, List<Map<String, dynamic>>> serviceGroups = {}; final serviceGroups = _groupMessagesByIdentifier(filteredSms);
// 서비스명별로 SMS 메시지 그룹화 Log.d('SmsScanner: 그룹화된 키 수: ${serviceGroups.length}');
for (final sms in smsList) {
final serviceName = sms['serviceName'] as String? ?? '알 수 없는 서비스';
if (!serviceGroups.containsKey(serviceName)) {
serviceGroups[serviceName] = [];
}
serviceGroups[serviceName]!.add(sms);
}
Log.d('SmsScanner: 그룹화된 서비스 수: ${serviceGroups.length}');
// 그룹화된 데이터로 구독 분석
for (final entry in serviceGroups.entries) { for (final entry in serviceGroups.entries) {
Log.d('SmsScanner: 서비스 "${entry.key}" - 메시지 개수: ${entry.value.length}'); Log.d('SmsScanner: 그룹 "${entry.key}" - 메시지 ${entry.value.length}');
final repeatResult = _detectRepeatingSubscriptions(entry.value);
// 2회 이상 반복된 서비스만 구독으로 간주 if (repeatResult == null) {
if (entry.value.length >= 2) { Log.d('SmsScanner: 반복 조건 불충족 - ${entry.key}');
// 결제일 패턴 유추를 위해 최근 2개의 결제일을 사용 continue;
final messages = [...entry.value];
messages.sort((a, b) {
final da = DateTime.tryParse(a['previousPaymentDate'] ?? '') ??
DateTime(1970);
final db = DateTime.tryParse(b['previousPaymentDate'] ?? '') ??
DateTime(1970);
return db.compareTo(da); // desc
});
final mostRecent = messages.first;
DateTime? recentDate =
DateTime.tryParse(mostRecent['previousPaymentDate'] ?? '');
DateTime? prevDate = messages.length > 1
? DateTime.tryParse(messages[1]['previousPaymentDate'] ?? '')
: null;
// 기본 결제 일자(일단위) 추정: 가장 최근 결제의 일자
int baseDay = recentDate?.day ?? DateTime.now().day;
// 이전 결제가 주말 이월로 보이는 패턴인지 검사하여 baseDay 보정
if (recentDate != null && prevDate != null) {
final candidate = DateTime(prevDate.year, prevDate.month, baseDay);
if (BusinessDayUtil.isWeekend(candidate)) {
final diff = prevDate.difference(candidate).inDays;
if (diff >= 1 && diff <= 3) {
// 예: 12일(토)→14일(월)
baseDay = baseDay; // 유지
} else {
// 차이가 크면 이전 달의 일자를 채택
baseDay = prevDate.day;
}
}
} }
// 다음 결제일 계산: 기준 일자를 바탕으로 다음 달 또는 이번 달로 설정 후 영업일 보정 final result =
final DateTime now = DateTime.now(); _parseSms(repeatResult.baseMessage, repeatResult.repeatCount);
int year = now.year; if (result != null) {
int month = now.month;
if (now.day >= baseDay) {
month += 1;
if (month > 12) {
month = 1;
year += 1;
}
}
final dim = BusinessDayUtil.daysInMonth(year, month);
final day = baseDay.clamp(1, dim);
DateTime nextBilling = DateTime(year, month, day);
nextBilling = BusinessDayUtil.nextBusinessDay(nextBilling);
// 가장 최근 SMS 맵에 override 값으로 주입
final serviceSms = Map<String, dynamic>.from(mostRecent);
serviceSms['overrideNextBillingDate'] = nextBilling.toIso8601String();
final subscription = _parseSms(serviceSms, entry.value.length);
if (subscription != null) {
Log.i( Log.i(
'SmsScanner: 구독 추가: ${subscription.serviceName}, 반복 횟수: ${subscription.repeatCount}'); 'SmsScanner: 구독 추가: ${result.model.serviceName}, 반복 횟수: ${result.model.repeatCount}');
subscriptions.add(subscription); subscriptions.add(result);
} else { } else {
Log.w('SmsScanner: 구독 파싱 실패: ${entry.key}'); Log.w('SmsScanner: 구독 파싱 실패: ${entry.key}');
} }
} else {
Log.d('SmsScanner: 반복 횟수 부족, 구독으로 간주하지 않음: ${entry.key}');
}
} }
Log.d('SmsScanner: 최종 구독 개수: ${subscriptions.length}'); Log.d('SmsScanner: 최종 구독 개수: ${subscriptions.length}');
@@ -161,7 +114,7 @@ class SmsScanner {
// (사용되지 않음) 기존 단일 메시지 파서 제거됨 - Isolate 배치 파싱으로 대체 // (사용되지 않음) 기존 단일 메시지 파서 제거됨 - Isolate 배치 파싱으로 대체
SubscriptionModel? _parseSms(Map<String, dynamic> sms, int repeatCount) { SmsScanResult? _parseSms(Map<String, dynamic> sms, int repeatCount) {
try { try {
final serviceName = sms['serviceName'] as String? ?? '알 수 없는 서비스'; final serviceName = sms['serviceName'] as String? ?? '알 수 없는 서비스';
final monthlyCost = (sms['monthlyCost'] as num?)?.toDouble() ?? 0.0; final monthlyCost = (sms['monthlyCost'] as num?)?.toDouble() ?? 0.0;
@@ -212,7 +165,7 @@ class SmsScanner {
adjustedNextBillingDate = adjustedNextBillingDate =
BusinessDayUtil.nextBusinessDay(adjustedNextBillingDate); BusinessDayUtil.nextBusinessDay(adjustedNextBillingDate);
return SubscriptionModel( final model = SubscriptionModel(
id: DateTime.now().millisecondsSinceEpoch.toString(), id: DateTime.now().millisecondsSinceEpoch.toString(),
serviceName: serviceName, serviceName: serviceName,
monthlyCost: monthlyCost, monthlyCost: monthlyCost,
@@ -224,11 +177,84 @@ class SmsScanner {
websiteUrl: _extractWebsiteUrl(serviceName), websiteUrl: _extractWebsiteUrl(serviceName),
currency: currency, // 통화 단위 설정 currency: currency, // 통화 단위 설정
); );
final suggestion = _extractPaymentCardSuggestion(message);
return SmsScanResult(
model: model,
cardSuggestion: suggestion,
rawMessage: message,
);
} catch (e) { } catch (e) {
return null; 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 _calculateNextBillingDate(
DateTime billingDate, String billingCycle) { DateTime billingDate, String billingCycle) {
@@ -342,43 +368,80 @@ class SmsScanner {
} }
} }
const List<String> _paymentLikeKeywords = [
'승인',
'결제',
'청구',
'charged',
'charge',
'payment',
'billed',
'purchase',
];
const List<String> _blockedKeywords = [
'otp',
'인증',
'보안',
'verification',
'code',
'코드',
'password',
'pw',
'일회성',
'1회용',
'보안문자',
];
bool _containsPaymentKeyword(String message) {
if (message.isEmpty) return false;
final normalized = message.toLowerCase();
return _paymentLikeKeywords.any(
(keyword) => normalized.contains(keyword.toLowerCase()),
);
}
bool _containsBlockedKeyword(String message) {
if (message.isEmpty) return false;
final normalized = message.toLowerCase();
return _blockedKeywords.any(
(keyword) => normalized.contains(keyword.toLowerCase()),
);
}
bool _isEligibleSubscriptionSms(Map<String, dynamic> sms) {
final amount = (sms['monthlyCost'] as num?)?.toDouble();
if (amount == null || amount <= 0) {
return false;
}
final message = sms['message'] as String? ?? '';
final isPaymentLike =
(sms['isPaymentLike'] as bool?) ?? _containsPaymentKeyword(message);
final isBlocked =
(sms['isBlocked'] as bool?) ?? _containsBlockedKeyword(message);
if (!isPaymentLike || isBlocked) {
return false;
}
return true;
}
// ===== Isolate 오프로딩용 Top-level 파서 ===== // ===== Isolate 오프로딩용 Top-level 파서 =====
// 대량 SMS 파싱: plugin 객체를 직렬화한 맵 리스트를 받아 구독 후보 맵 리스트로 변환 // 대량 SMS 파싱: plugin 객체를 직렬화한 맵 리스트를 받아 구독 후보 맵 리스트로 변환
List<Map<String, dynamic>> _parseRawSmsBatch( List<Map<String, dynamic>> _parseRawSmsBatch(
List<Map<String, dynamic>> messages) { List<Map<String, dynamic>> messages) {
// 키워드/정규식은 Isolate 내에서 재생성 (간단 복제)
const subscriptionKeywords = [
'구독',
'결제',
'정기결제',
'자동결제',
'월정액',
'subscription',
'payment',
'billing',
'charge',
'넷플릭스',
'Netflix',
'유튜브',
'YouTube',
'Spotify',
'멜론',
'웨이브',
'Disney+',
'디즈니플러스',
'Apple',
'Microsoft',
'GitHub',
'Adobe',
'Amazon'
];
final amountPatterns = <RegExp>[ final amountPatterns = <RegExp>[
RegExp(r'(\d{1,3}(?:,\d{3})*)(?:원|₩)'), // 원화 RegExp(r'(\d{1,3}(?:,\d{3})*(?:\.\d{1,2})?)\s*(?:원|₩)'),
RegExp(r'\$(\d+(?:\.\d{2})?)'), // 달러 RegExp(r'(?:원|₩)\s*(\d{1,3}(?:,\d{3})*(?:\.\d{1,2})?)'),
RegExp(r'(\d+(?:\.\d{2})?)\s*USD'), // USD RegExp(r'(?:(?:US)?\$)\s*(\d+(?:\.\d{1,2})?)', caseSensitive: false),
RegExp(r'결제.*?(\d{1,3}(?:,\d{3})*)'), // 결제 금액 RegExp(r'(\d+(?:,\d{3})*(?:\.\d{1,2})?)\s*(?:USD|KRW)',
caseSensitive: false),
RegExp(r'(?:USD|KRW)\s*(\d+(?:,\d{3})*(?:\.\d{1,2})?)',
caseSensitive: false),
RegExp(r'(?:결제|승인)[^0-9]{0,12}(\d{1,3}(?:,\d{3})*(?:\.\d{1,2})?)'),
]; ];
final results = <Map<String, dynamic>>[]; final results = <Map<String, dynamic>>[];
@@ -389,28 +452,26 @@ List<Map<String, dynamic>> _parseRawSmsBatch(
final dateMillis = final dateMillis =
(m['dateMillis'] as int?) ?? DateTime.now().millisecondsSinceEpoch; (m['dateMillis'] as int?) ?? DateTime.now().millisecondsSinceEpoch;
final date = DateTime.fromMillisecondsSinceEpoch(dateMillis); final date = DateTime.fromMillisecondsSinceEpoch(dateMillis);
final lowerBody = body.toLowerCase();
final lowerSender = sender.toLowerCase();
final isSubscription = subscriptionKeywords.any((k) =>
lowerBody.contains(k.toLowerCase()) ||
lowerSender.contains(k.toLowerCase()));
if (!isSubscription) continue;
final serviceName = _isoExtractServiceName(body, sender); final serviceName = _isoExtractServiceName(body, sender);
final amount = _isoExtractAmount(body, amountPatterns) ?? 0.0; final amount = _isoExtractAmount(body, amountPatterns);
final isPaymentLike = _containsPaymentKeyword(body);
final isBlocked = _containsBlockedKeyword(body);
final billingCycle = _isoExtractBillingCycle(body); final billingCycle = _isoExtractBillingCycle(body);
final nextBillingDate = final nextBillingDate =
_isoCalculateNextBillingFromDate(date, billingCycle); _isoCalculateNextBillingFromDate(date, billingCycle);
final normalizedBody = _isoNormalizeBody(body);
results.add({ results.add({
'serviceName': serviceName, 'serviceName': serviceName,
'address': sender,
'monthlyCost': amount, 'monthlyCost': amount,
'billingCycle': billingCycle, 'billingCycle': billingCycle,
'message': body, 'message': body,
'normalizedBody': normalizedBody,
'nextBillingDate': nextBillingDate.toIso8601String(), 'nextBillingDate': nextBillingDate.toIso8601String(),
'previousPaymentDate': date.toIso8601String(), 'previousPaymentDate': date.toIso8601String(),
'isPaymentLike': isPaymentLike,
'isBlocked': isBlocked,
}); });
} }
@@ -473,6 +534,23 @@ String _isoExtractBillingCycle(String body) {
return 'monthly'; return 'monthly';
} }
String _isoNormalizeBody(String body) {
final patterns = <RegExp>[
RegExp(r'\d{4}[./-]\d{1,2}[./-]\d{1,2}'),
RegExp(r'\d{1,2}[./-]\d{1,2}[./-]\d{2,4}'),
RegExp(r'\d{4}\s*년\s*\d{1,2}\s*월\s*\d{1,2}\s*일'),
RegExp(r'\d{1,2}\s*월\s*\d{1,2}\s*일'),
RegExp(r'\d{1,2}:\d{2}'),
];
var normalized = body;
for (final pattern in patterns) {
normalized = normalized.replaceAll(pattern, ' ');
}
return normalized.replaceAll(RegExp(r'\s+'), ' ').trim().toLowerCase();
}
DateTime _isoCalculateNextBillingFromDate( DateTime _isoCalculateNextBillingFromDate(
DateTime lastDate, String billingCycle) { DateTime lastDate, String billingCycle) {
switch (billingCycle) { switch (billingCycle) {
@@ -486,3 +564,260 @@ DateTime _isoCalculateNextBillingFromDate(
return lastDate.add(const Duration(days: 30)); return lastDate.add(const Duration(days: 30));
} }
} }
Map<String, List<Map<String, dynamic>>> _groupMessagesByIdentifier(
List<dynamic> smsList) {
final Map<String, List<Map<String, dynamic>>> groups = {};
for (final smsEntry in smsList) {
if (smsEntry is! Map) continue;
final sms = Map<String, dynamic>.from(smsEntry as Map<String, dynamic>);
final serviceName = (sms['serviceName'] as String?)?.trim();
final address = (sms['address'] as String?)?.trim();
final sender = (sms['sender'] as String?)?.trim();
String key = (serviceName != null &&
serviceName.isNotEmpty &&
serviceName != '알 수 없는 서비스')
? serviceName
: (address?.isNotEmpty == true
? address!
: (sender?.isNotEmpty == true ? sender! : 'unknown'));
groups.putIfAbsent(key, () => []).add(sms);
}
return groups;
}
class _RepeatDetectionResult {
_RepeatDetectionResult({
required this.baseMessage,
required this.repeatCount,
});
final Map<String, dynamic> baseMessage;
final int repeatCount;
}
enum _MatchType { none, monthly, yearly, identical }
class _MatchedPair {
_MatchedPair(this.first, this.second, this.type);
final int first;
final int second;
final _MatchType type;
}
_RepeatDetectionResult? _detectRepeatingSubscriptions(
List<Map<String, dynamic>> messages) {
if (messages.length < 2) return null;
final sorted = messages.map((sms) => Map<String, dynamic>.from(sms)).toList()
..sort((a, b) {
final da = _parsePaymentDate(a['previousPaymentDate']);
final db = _parsePaymentDate(b['previousPaymentDate']);
return (db ?? DateTime.fromMillisecondsSinceEpoch(0))
.compareTo(da ?? DateTime.fromMillisecondsSinceEpoch(0));
});
final matchedIndices = <int>{};
final matchedPairs = <_MatchedPair>[];
for (int i = 0; i < sorted.length - 1; i++) {
for (int j = i + 1; j < sorted.length && j <= i + 5; j++) {
final matchType = _evaluateMatch(sorted[i], sorted[j]);
if (matchType == _MatchType.none) continue;
matchedIndices.add(i);
matchedIndices.add(j);
matchedPairs.add(_MatchedPair(i, j, matchType));
break;
}
}
if (matchedIndices.length < 2) return null;
final hasValidInterval = matchedPairs.any((pair) =>
pair.type == _MatchType.monthly || pair.type == _MatchType.yearly);
if (!hasValidInterval) return null;
final baseIndex = matchedIndices
.reduce((value, element) => value < element ? value : element);
final baseMessage = Map<String, dynamic>.from(sorted[baseIndex]);
final overrideDate = _deriveNextBillingDate(sorted, matchedPairs);
if (overrideDate != null) {
baseMessage['overrideNextBillingDate'] = overrideDate.toIso8601String();
}
return _RepeatDetectionResult(
baseMessage: baseMessage,
repeatCount: matchedIndices.length,
);
}
_MatchType _evaluateMatch(
Map<String, dynamic> recent, Map<String, dynamic> previous) {
final amountMatch = _matchByAmountAndInterval(recent, previous);
if (amountMatch != _MatchType.none) {
return amountMatch;
}
if (_areBodiesEquivalent(recent, previous)) {
final inferredInterval = _classifyIntervalByDates(recent, previous);
return inferredInterval == _MatchType.none
? _MatchType.identical
: inferredInterval;
}
return _MatchType.none;
}
_MatchType _matchByAmountAndInterval(
Map<String, dynamic> a, Map<String, dynamic> b) {
final amountA = (a['monthlyCost'] as num?)?.toDouble();
final amountB = (b['monthlyCost'] as num?)?.toDouble();
if (amountA == null || amountB == null) return _MatchType.none;
if (!_isAmountSimilar(amountA, amountB)) return _MatchType.none;
return _classifyIntervalByDates(a, b);
}
_MatchType _classifyIntervalByDates(
Map<String, dynamic> a, Map<String, dynamic> b) {
final dateA = _parsePaymentDate(a['previousPaymentDate']);
final dateB = _parsePaymentDate(b['previousPaymentDate']);
if (dateA == null || dateB == null) return _MatchType.none;
final diffDays = (dateA.difference(dateB).inDays).abs();
if (diffDays >= 27 && diffDays <= 34) {
return _MatchType.monthly;
}
if (diffDays >= 350 && diffDays <= 380) {
return _MatchType.yearly;
}
return _MatchType.none;
}
bool _areBodiesEquivalent(Map<String, dynamic> a, Map<String, dynamic> b) {
final normalizedA = _getNormalizedBody(a);
final normalizedB = _getNormalizedBody(b);
if (normalizedA.isEmpty || normalizedB.isEmpty) return false;
return normalizedA == normalizedB;
}
String _getNormalizedBody(Map<String, dynamic> sms) {
final cached = sms['normalizedBody'] as String?;
if (cached != null && cached.isNotEmpty) return cached;
final message = sms['message'] as String? ?? '';
final normalized = _isoNormalizeBody(message);
sms['normalizedBody'] = normalized;
return normalized;
}
DateTime? _deriveNextBillingDate(
List<Map<String, dynamic>> sorted, List<_MatchedPair> pairs) {
if (pairs.isEmpty) return null;
final targetPair = pairs.firstWhere(
(pair) => pair.type == _MatchType.monthly || pair.type == _MatchType.yearly,
orElse: () => pairs.first,
);
final recent = sorted[targetPair.first];
final previous = sorted[targetPair.second];
final recentDate = _parsePaymentDate(recent['previousPaymentDate']);
final prevDate = _parsePaymentDate(previous['previousPaymentDate']);
return _calculateNextBillingFromPair(recentDate, prevDate, targetPair.type);
}
DateTime? _calculateNextBillingFromPair(
DateTime? recentDate, DateTime? prevDate, _MatchType type) {
if (recentDate == null) return null;
if (type == _MatchType.monthly) {
DateTime candidate = _addMonths(recentDate, 1);
while (!candidate.isAfter(DateTime.now())) {
candidate = _addMonths(candidate, 1);
}
return BusinessDayUtil.nextBusinessDay(candidate);
}
if (type == _MatchType.yearly) {
DateTime candidate = DateTime(
recentDate.year + 1,
recentDate.month,
_clampDay(
recentDate.day,
BusinessDayUtil.daysInMonth(recentDate.year + 1, recentDate.month),
),
);
while (!candidate.isAfter(DateTime.now())) {
candidate = DateTime(candidate.year + 1, candidate.month, candidate.day);
}
return BusinessDayUtil.nextBusinessDay(candidate);
}
return _inferMonthlyNextBilling(recentDate, prevDate);
}
DateTime? _inferMonthlyNextBilling(DateTime recentDate, DateTime? prevDate) {
int baseDay = recentDate.day;
if (prevDate != null) {
final candidate = DateTime(prevDate.year, prevDate.month, baseDay);
if (BusinessDayUtil.isWeekend(candidate)) {
final diff = prevDate.difference(candidate).inDays;
if (diff < 1 || diff > 3) {
baseDay = prevDate.day;
}
}
}
final now = DateTime.now();
int year = now.year;
int month = now.month;
if (now.day >= baseDay) {
month += 1;
if (month > 12) {
month = 1;
year += 1;
}
}
final dim = BusinessDayUtil.daysInMonth(year, month);
final day = _clampDay(baseDay, dim);
var nextBilling = DateTime(year, month, day);
return BusinessDayUtil.nextBusinessDay(nextBilling);
}
DateTime _addMonths(DateTime date, int months) {
final totalMonths = (date.month - 1) + months;
final year = date.year + totalMonths ~/ 12;
final month = totalMonths % 12 + 1;
final dim = BusinessDayUtil.daysInMonth(year, month);
final day = _clampDay(date.day, dim);
return DateTime(year, month, day);
}
int _clampDay(int day, int maxDay) {
if (day < 1) return 1;
if (day > maxDay) return maxDay;
return day;
}
DateTime? _parsePaymentDate(dynamic value) {
if (value is DateTime) return value;
if (value is String && value.isNotEmpty) {
return DateTime.tryParse(value);
}
return null;
}
bool _isAmountSimilar(double a, double b) {
final diff = (a - b).abs();
final base = math.max(a.abs(), b.abs());
final tolerance = base * 0.01; // 1% 허용
final minTolerance = base < 10 ? 0.1 : 1.0;
return diff <= math.max(tolerance, minTolerance);
}

View File

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

View File

@@ -0,0 +1,41 @@
import 'package:flutter/material.dart';
/// 결제수단 관련 공통 유틸리티
class PaymentCardUtils {
static const List<String> colorPalette = [
'#FF6B6B',
'#F97316',
'#F59E0B',
'#10B981',
'#06B6D4',
'#3B82F6',
'#6366F1',
'#8B5CF6',
'#EC4899',
'#14B8A6',
'#0EA5E9',
'#94A3B8',
];
static const Map<String, IconData> 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));
}
}

View File

@@ -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<SubscriptionModel> 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<SubscriptionGroupData> buildGroups({
required BuildContext context,
required List<SubscriptionModel> 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<SubscriptionGroupData> _groupByCategory({
required BuildContext context,
required List<SubscriptionModel> subscriptions,
required CategoryProvider categoryProvider,
}) {
final localizedMap = SubscriptionCategoryHelper.categorizeSubscriptions(
subscriptions, categoryProvider, context);
final orderMap = <String, int>{};
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<SubscriptionGroupData> _groupByPaymentCard({
required BuildContext context,
required List<SubscriptionModel> subscriptions,
required PaymentCardProvider paymentCardProvider,
}) {
final map = <String, List<SubscriptionModel>>{};
for (final sub in subscriptions) {
final key = sub.paymentCardId ?? _unassignedCardKey;
map.putIfAbsent(key, () => []).add(sub);
}
final loc = AppLocalizations.of(context);
final groups = <SubscriptionGroupData>[];
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;
}
}

View File

@@ -10,6 +10,9 @@ import '../common/form_fields/date_picker_field.dart';
import '../common/form_fields/currency_dropdown_field.dart'; import '../common/form_fields/currency_dropdown_field.dart';
import '../common/form_fields/billing_cycle_selector.dart'; import '../common/form_fields/billing_cycle_selector.dart';
import '../common/form_fields/category_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 사용 // Glass 제거: Material 3 Card 사용
// Material colors only // 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);
},
),
], ],
), ),
), ),

View File

@@ -1,7 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.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'; import '../../services/currency_util.dart';
// Glass 제거: Material 3 Card 사용 // Glass 제거: Material 3 Card 사용
import '../themed_text.dart'; import '../themed_text.dart';
@@ -11,21 +11,31 @@ import '../../theme/color_scheme_ext.dart';
/// 이벤트 할인 현황을 보여주는 카드 위젯 /// 이벤트 할인 현황을 보여주는 카드 위젯
class EventAnalysisCard extends StatelessWidget { class EventAnalysisCard extends StatelessWidget {
final AnimationController animationController; final AnimationController animationController;
final List<SubscriptionModel> subscriptions;
const EventAnalysisCard({ const EventAnalysisCard({
super.key, super.key,
required this.animationController, required this.animationController,
required this.subscriptions,
}); });
@override @override
Widget build(BuildContext context) { 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<double>(
0,
(sum, sub) => sum + sub.eventSavings,
);
return SliverToBoxAdapter( return SliverToBoxAdapter(
child: Consumer<SubscriptionProvider>( child: Padding(
builder: (context, provider, child) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16), padding: const EdgeInsets.symmetric(horizontal: 16),
child: provider.activeEventSubscriptions.isNotEmpty child: FadeTransition(
? FadeTransition(
opacity: CurvedAnimation( opacity: CurvedAnimation(
parent: animationController, parent: animationController,
curve: const Interval(0.6, 1.0, curve: Curves.easeOut), curve: const Interval(0.6, 1.0, curve: Curves.easeOut),
@@ -34,10 +44,12 @@ class EventAnalysisCard extends StatelessWidget {
position: Tween<Offset>( position: Tween<Offset>(
begin: const Offset(0, 0.2), begin: const Offset(0, 0.2),
end: Offset.zero, end: Offset.zero,
).animate(CurvedAnimation( ).animate(
CurvedAnimation(
parent: animationController, parent: animationController,
curve: const Interval(0.6, 1.0, curve: Curves.easeOut), curve: const Interval(0.6, 1.0, curve: Curves.easeOut),
)), ),
),
child: Card( child: Card(
elevation: 3, elevation: 3,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
@@ -55,15 +67,12 @@ class EventAnalysisCard extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Row( Row(
mainAxisAlignment: mainAxisAlignment: MainAxisAlignment.spaceBetween,
MainAxisAlignment.spaceBetween,
children: [ children: [
ThemedText.headline( ThemedText.headline(
text: AppLocalizations.of(context) text:
.eventDiscountStatus, AppLocalizations.of(context).eventDiscountStatus,
style: const TextStyle( style: const TextStyle(fontSize: 18),
fontSize: 18,
),
), ),
Container( Container(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
@@ -71,8 +80,7 @@ class EventAnalysisCard extends StatelessWidget {
vertical: 4, vertical: 4,
), ),
decoration: BoxDecoration( decoration: BoxDecoration(
color: color: Theme.of(context).colorScheme.error,
Theme.of(context).colorScheme.error,
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),
), ),
child: Row( child: Row(
@@ -80,22 +88,16 @@ class EventAnalysisCard extends StatelessWidget {
FaIcon( FaIcon(
FontAwesomeIcons.fire, FontAwesomeIcons.fire,
size: 12, size: 12,
color: Theme.of(context) color: Theme.of(context).colorScheme.onError,
.colorScheme
.onError,
), ),
const SizedBox(width: 4), const SizedBox(width: 4),
Text( Text(
AppLocalizations.of(context) AppLocalizations.of(context).servicesInProgress(
.servicesInProgress(provider activeEventSubscriptions.length),
.activeEventSubscriptions
.length),
style: TextStyle( style: TextStyle(
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: Theme.of(context) color: Theme.of(context).colorScheme.onError,
.colorScheme
.onError,
), ),
), ),
], ],
@@ -110,28 +112,26 @@ class EventAnalysisCard extends StatelessWidget {
color: Theme.of(context) color: Theme.of(context)
.colorScheme .colorScheme
.error .error
.withValues(alpha: 0.08), .withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(12),
border: Border.all( border: Border.all(
color: Theme.of(context) color: Theme.of(context)
.colorScheme .colorScheme
.error .error
.withValues(alpha: 0.3), .withValues(alpha: 0.2),
), ),
), ),
child: Row( child: Row(
children: [ children: [
Icon( Icon(
Icons.savings, Icons.savings,
color: color: Theme.of(context).colorScheme.error,
Theme.of(context).colorScheme.error,
size: 32, size: 32,
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: crossAxisAlignment: CrossAxisAlignment.start,
CrossAxisAlignment.start,
children: [ children: [
ThemedText( ThemedText(
AppLocalizations.of(context) AppLocalizations.of(context)
@@ -143,15 +143,11 @@ class EventAnalysisCard extends StatelessWidget {
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
ThemedText( ThemedText(
CurrencyUtil.formatTotalAmount( CurrencyUtil.formatTotalAmount(totalSavings),
provider.calculateTotalSavings(),
),
style: TextStyle( style: TextStyle(
fontSize: 20, fontSize: 20,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: Theme.of(context) color: Theme.of(context).colorScheme.error,
.colorScheme
.error,
), ),
), ),
], ],
@@ -169,12 +165,11 @@ class EventAnalysisCard extends StatelessWidget {
), ),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
...provider.activeEventSubscriptions.map((sub) { ...activeEventSubscriptions.map((sub) {
final savings = sub.originalPrice - final savings = sub.originalPrice -
(sub.eventPrice ?? sub.originalPrice); (sub.eventPrice ?? sub.originalPrice);
final discountRate = final discountRate =
((savings / sub.originalPrice) * 100) ((savings / sub.originalPrice) * 100).round();
.round();
return Container( return Container(
margin: const EdgeInsets.only(bottom: 8), margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
@@ -195,8 +190,7 @@ class EventAnalysisCard extends StatelessWidget {
children: [ children: [
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: crossAxisAlignment: CrossAxisAlignment.start,
CrossAxisAlignment.start,
children: [ children: [
ThemedText( ThemedText(
sub.serviceName, sub.serviceName,
@@ -209,10 +203,8 @@ class EventAnalysisCard extends StatelessWidget {
Row( Row(
children: [ children: [
FutureBuilder<String>( FutureBuilder<String>(
future: future: CurrencyUtil.formatAmount(
CurrencyUtil.formatAmount( sub.originalPrice, sub.currency),
sub.originalPrice,
sub.currency),
builder: (context, snapshot) { builder: (context, snapshot) {
if (snapshot.hasData) { if (snapshot.hasData) {
return ThemedText( return ThemedText(
@@ -220,10 +212,8 @@ class EventAnalysisCard extends StatelessWidget {
style: TextStyle( style: TextStyle(
fontSize: 12, fontSize: 12,
decoration: decoration:
TextDecoration TextDecoration.lineThrough,
.lineThrough, color: Theme.of(context)
color: Theme.of(
context)
.colorScheme .colorScheme
.onSurfaceVariant, .onSurfaceVariant,
), ),
@@ -242,21 +232,18 @@ class EventAnalysisCard extends StatelessWidget {
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
FutureBuilder<String>( FutureBuilder<String>(
future: future: CurrencyUtil.formatAmount(
CurrencyUtil.formatAmount( sub.eventPrice ?? sub.originalPrice,
sub.eventPrice ?? sub.currency,
sub.originalPrice, ),
sub.currency),
builder: (context, snapshot) { builder: (context, snapshot) {
if (snapshot.hasData) { if (snapshot.hasData) {
return ThemedText( return ThemedText(
snapshot.data!, snapshot.data!,
style: TextStyle( style: TextStyle(
fontSize: 12, fontSize: 12,
fontWeight: fontWeight: FontWeight.bold,
FontWeight.bold, color: Theme.of(context)
color:
Theme.of(context)
.colorScheme .colorScheme
.success, .success,
), ),
@@ -280,18 +267,14 @@ class EventAnalysisCard extends StatelessWidget {
.colorScheme .colorScheme
.error .error
.withValues(alpha: 0.2), .withValues(alpha: 0.2),
borderRadius: borderRadius: BorderRadius.circular(4),
BorderRadius.circular(4),
), ),
child: Text( child: Text(
_formatDiscountPercent( _formatDiscountPercent(context, discountRate),
context, discountRate),
style: TextStyle( style: TextStyle(
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: Theme.of(context) color: Theme.of(context).colorScheme.error,
.colorScheme
.error,
), ),
), ),
), ),
@@ -304,10 +287,7 @@ class EventAnalysisCard extends StatelessWidget {
), ),
), ),
), ),
) ),
: const SizedBox.shrink(),
);
},
), ),
); );
} }

View File

@@ -77,6 +77,12 @@ class AppNavigator {
await Navigator.of(context).pushNamed(AppRoutes.settings); await Navigator.of(context).pushNamed(AppRoutes.settings);
} }
/// 결제수단 관리 화면으로 네비게이션
static Future<void> toPaymentCardManagement(BuildContext context) async {
HapticFeedback.lightImpact();
await Navigator.of(context).pushNamed(AppRoutes.paymentCardManagement);
}
/// 카테고리 관리 화면으로 네비게이션 /// 카테고리 관리 화면으로 네비게이션
static Future<void> toCategoryManagement(BuildContext context) async { static Future<void> toCategoryManagement(BuildContext context) async {
HapticFeedback.lightImpact(); HapticFeedback.lightImpact();

View File

@@ -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 = <String>[];
// 개수는 항상 표시
parts
.add(AppLocalizations.of(context).subscriptionCount(subscriptionCount));
// 통화 부분을 별도로 처리
final currencyParts = <String>[];
// 달러가 있는 경우
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(' · ');
}
}

View File

@@ -9,6 +9,9 @@ import '../common/form_fields/date_picker_field.dart';
import '../common/form_fields/currency_dropdown_field.dart'; import '../common/form_fields/currency_dropdown_field.dart';
import '../common/form_fields/billing_cycle_selector.dart'; import '../common/form_fields/billing_cycle_selector.dart';
import '../common/form_fields/category_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'; 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);
},
),
], ],
), ),
), ),

View File

@@ -3,7 +3,12 @@ import 'package:provider/provider.dart';
import '../../models/subscription_model.dart'; import '../../models/subscription_model.dart';
import '../../controllers/detail_screen_controller.dart'; import '../../controllers/detail_screen_controller.dart';
import '../../providers/locale_provider.dart'; import '../../providers/locale_provider.dart';
import '../../providers/payment_card_provider.dart';
import '../../services/currency_util.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 '../website_icon.dart';
import '../../l10n/app_localizations.dart'; import '../../l10n/app_localizations.dart';
@@ -30,9 +35,13 @@ class DetailHeaderSection extends StatelessWidget {
return Consumer<DetailScreenController>( return Consumer<DetailScreenController>(
builder: (context, controller, child) { builder: (context, controller, child) {
final baseColor = controller.getCardColor(); final baseColor = controller.getCardColor();
final paymentCardProvider = context.watch<PaymentCardProvider>();
final paymentCard = paymentCardProvider.getCardById(
controller.selectedPaymentCardId ?? subscription.paymentCardId,
);
return Container( return Container(
height: 320, constraints: const BoxConstraints(minHeight: 320),
decoration: BoxDecoration(color: baseColor), decoration: BoxDecoration(color: baseColor),
child: Stack( child: Stack(
children: [ children: [
@@ -69,6 +78,7 @@ class DetailHeaderSection extends StatelessWidget {
child: Padding( child: Padding(
padding: const EdgeInsets.all(24), padding: const EdgeInsets.all(24),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// 뒤로가기 버튼 // 뒤로가기 버튼
@@ -91,7 +101,7 @@ class DetailHeaderSection extends StatelessWidget {
), ),
], ],
), ),
const Spacer(), const SizedBox(height: 16),
// 서비스 정보 // 서비스 정보
FadeTransition( FadeTransition(
opacity: fadeAnimation, opacity: fadeAnimation,
@@ -172,6 +182,11 @@ class DetailHeaderSection extends StatelessWidget {
.withValues(alpha: 0.8), .withValues(alpha: 0.8),
), ),
), ),
const SizedBox(height: 12),
_buildPaymentCardChip(
context,
paymentCard,
),
], ],
), ),
), ),
@@ -186,17 +201,19 @@ class DetailHeaderSection extends StatelessWidget {
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
), ),
child: Row( child: Row(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [ children: [
_InfoColumn( Expanded(
child: _InfoColumn(
label: AppLocalizations.of(context) label: AppLocalizations.of(context)
.nextBillingDate, .nextBillingDate,
value: AppLocalizations.of(context) value: AppLocalizations.of(context)
.formatDate( .formatDate(
controller.nextBillingDate), controller.nextBillingDate),
), ),
FutureBuilder<String>( ),
const SizedBox(width: 12),
Expanded(
child: FutureBuilder<String>(
future: () async { future: () async {
final locale = context final locale = context
.read<LocaleProvider>() .read<LocaleProvider>()
@@ -204,7 +221,8 @@ class DetailHeaderSection extends StatelessWidget {
.languageCode; .languageCode;
final amount = double.tryParse( final amount = double.tryParse(
controller controller
.monthlyCostController.text .monthlyCostController
.text
.replaceAll(',', '')) ?? .replaceAll(',', '')) ??
0; 0;
return CurrencyUtil return CurrencyUtil
@@ -220,9 +238,11 @@ class DetailHeaderSection extends StatelessWidget {
.monthlyExpense, .monthlyExpense,
value: snapshot.data ?? '-', value: snapshot.data ?? '-',
alignment: CrossAxisAlignment.end, alignment: CrossAxisAlignment.end,
wrapValue: true,
); );
}, },
), ),
),
], ],
), ),
), ),
@@ -268,6 +288,104 @@ class DetailHeaderSection extends StatelessWidget {
return cycle; 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),
),
],
),
),
);
}
} }
/// 정보 표시 컬럼 /// 정보 표시 컬럼
@@ -275,11 +393,13 @@ class _InfoColumn extends StatelessWidget {
final String label; final String label;
final String value; final String value;
final CrossAxisAlignment alignment; final CrossAxisAlignment alignment;
final bool wrapValue;
const _InfoColumn({ const _InfoColumn({
required this.label, required this.label,
required this.value, required this.value,
this.alignment = CrossAxisAlignment.start, this.alignment = CrossAxisAlignment.start,
this.wrapValue = false,
}); });
@override @override
@@ -296,6 +416,19 @@ class _InfoColumn extends StatelessWidget {
), ),
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
if (wrapValue)
Text(
value,
textAlign: TextAlign.end,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: Colors.white,
),
)
else
Text( Text(
value, value,
style: const TextStyle( style: const TextStyle(

View File

@@ -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<double> fadeAnimation;
final Animation<Offset> slideAnimation;
const DetailPaymentInfoSection({
super.key,
required this.controller,
required this.fadeAnimation,
required this.slideAnimation,
});
@override
Widget build(BuildContext context) {
return Consumer2<DetailScreenController, PaymentCardProvider>(
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,
),
],
),
),
),
);
}
}

View File

@@ -1,16 +1,18 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../providers/subscription_provider.dart'; import 'package:shared_preferences/shared_preferences.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';
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 fadeController;
final AnimationController rotateController; final AnimationController rotateController;
final AnimationController slideController; final AnimationController slideController;
@@ -31,10 +33,53 @@ class HomeContent extends StatelessWidget {
}); });
@override @override
Widget build(BuildContext context) { State<HomeContent> createState() => _HomeContentState();
final provider = context.watch<SubscriptionProvider>(); }
if (provider.isLoading) { class _HomeContentState extends State<HomeContent> {
static const _groupingPrefKey = 'home_grouping_mode';
SubscriptionGroupingMode _groupingMode = SubscriptionGroupingMode.category;
@override
void initState() {
super.initState();
_loadGroupingPreference();
}
Future<void> _loadGroupingPreference() async {
final prefs = await SharedPreferences.getInstance();
final stored = prefs.getString(_groupingPrefKey);
if (stored == 'paymentCard') {
setState(() {
_groupingMode = SubscriptionGroupingMode.paymentCard;
});
}
}
Future<void> _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<SubscriptionProvider>();
final categoryProvider = context.watch<CategoryProvider>();
final paymentCardProvider = context.watch<PaymentCardProvider>();
if (subscriptionProvider.isLoading) {
return Center( return Center(
child: CircularProgressIndicator( child: CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>( valueColor: AlwaysStoppedAnimation<Color>(
@@ -44,32 +89,30 @@ class HomeContent extends StatelessWidget {
); );
} }
if (provider.subscriptions.isEmpty) { if (subscriptionProvider.subscriptions.isEmpty) {
return EmptyStateWidget( return EmptyStateWidget(
fadeController: fadeController, fadeController: widget.fadeController,
rotateController: rotateController, rotateController: widget.rotateController,
slideController: slideController, slideController: widget.slideController,
onAddPressed: onAddPressed, onAddPressed: widget.onAddPressed,
); );
} }
// 카테고리별 구독 구분 final groupedSubscriptions = SubscriptionGroupingHelper.buildGroups(
final categoryProvider = context: context,
Provider.of<CategoryProvider>(context, listen: false); subscriptions: subscriptionProvider.subscriptions,
final categorizedSubscriptions = mode: _groupingMode,
SubscriptionCategoryHelper.categorizeSubscriptions( categoryProvider: categoryProvider,
provider.subscriptions, paymentCardProvider: paymentCardProvider,
categoryProvider,
context,
); );
return RefreshIndicator( return RefreshIndicator(
onRefresh: () async { onRefresh: () async {
await provider.refreshSubscriptions(); await subscriptionProvider.refreshSubscriptions();
}, },
color: Theme.of(context).colorScheme.primary, color: Theme.of(context).colorScheme.primary,
child: CustomScrollView( child: CustomScrollView(
controller: scrollController, controller: widget.scrollController,
physics: const BouncingScrollPhysics(), physics: const BouncingScrollPhysics(),
slivers: [ slivers: [
SliverToBoxAdapter( SliverToBoxAdapter(
@@ -86,13 +129,13 @@ class HomeContent extends StatelessWidget {
begin: const Offset(0, 0.2), begin: const Offset(0, 0.2),
end: Offset.zero, end: Offset.zero,
).animate(CurvedAnimation( ).animate(CurvedAnimation(
parent: slideController, curve: Curves.easeOutCubic)), parent: widget.slideController, curve: Curves.easeOutCubic)),
child: MainScreenSummaryCard( child: MainScreenSummaryCard(
provider: provider, provider: subscriptionProvider,
fadeController: fadeController, fadeController: widget.fadeController,
pulseController: pulseController, pulseController: widget.pulseController,
waveController: waveController, waveController: widget.waveController,
slideController: slideController, slideController: widget.slideController,
), ),
), ),
), ),
@@ -107,7 +150,8 @@ class HomeContent extends StatelessWidget {
begin: const Offset(-0.2, 0), begin: const Offset(-0.2, 0),
end: Offset.zero, end: Offset.zero,
).animate(CurvedAnimation( ).animate(CurvedAnimation(
parent: slideController, curve: Curves.easeOutCubic)), parent: widget.slideController,
curve: Curves.easeOutCubic)),
child: Text( child: Text(
AppLocalizations.of(context).mySubscriptions, AppLocalizations.of(context).mySubscriptions,
style: Theme.of(context).textTheme.titleLarge?.copyWith( style: Theme.of(context).textTheme.titleLarge?.copyWith(
@@ -120,12 +164,13 @@ class HomeContent extends StatelessWidget {
begin: const Offset(0.2, 0), begin: const Offset(0.2, 0),
end: Offset.zero, end: Offset.zero,
).animate(CurvedAnimation( ).animate(CurvedAnimation(
parent: slideController, curve: Curves.easeOutCubic)), parent: widget.slideController,
curve: Curves.easeOutCubic)),
child: Row( child: Row(
children: [ children: [
Text( Text(
AppLocalizations.of(context) AppLocalizations.of(context).subscriptionCount(
.subscriptionCount(provider.subscriptions.length), subscriptionProvider.subscriptions.length),
style: TextStyle( style: TextStyle(
fontSize: 14, fontSize: 14,
fontWeight: FontWeight.w600, 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( SubscriptionListWidget(
categorizedSubscriptions: categorizedSubscriptions, groups: groupedSubscriptions,
fadeController: fadeController, fadeController: widget.fadeController,
), ),
SliverToBoxAdapter( SliverToBoxAdapter(
child: SizedBox( child: SizedBox(

View File

@@ -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<String?> show(
BuildContext context, {
PaymentCardModel? card,
String? initialIssuerName,
String? initialLast4,
String? initialColorHex,
String? initialIconName,
}) async {
return showModalBottomSheet<String>(
context: context,
isScrollControlled: true,
useSafeArea: true,
builder: (_) => PaymentCardFormSheet(
card: card,
initialIssuerName: initialIssuerName,
initialLast4: initialLast4,
initialColorHex: initialColorHex,
initialIconName: initialIconName,
),
);
}
@override
State<PaymentCardFormSheet> createState() => _PaymentCardFormSheetState();
}
class _PaymentCardFormSheetState extends State<PaymentCardFormSheet> {
final _formKey = GlobalKey<FormState>();
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<void> _handleSubmit() async {
if (!_formKey.currentState!.validate()) return;
setState(() {
_isSaving = true;
});
try {
final provider = context.read<PaymentCardProvider>();
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;
});
}
}
}
}

View File

@@ -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<String?> onChanged;
final Future<void> 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<PaymentCardProvider>(
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),
),
),
);
}
}

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../../models/subscription.dart'; import '../../models/subscription.dart';
import '../../models/payment_card_suggestion.dart';
import '../../providers/category_provider.dart'; import '../../providers/category_provider.dart';
import '../../providers/locale_provider.dart'; import '../../providers/locale_provider.dart';
import '../../widgets/themed_text.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/form_fields/category_selector.dart';
import '../../widgets/common/snackbar/app_snackbar.dart'; import '../../widgets/common/snackbar/app_snackbar.dart';
import '../../widgets/native_ad_widget.dart'; import '../../widgets/native_ad_widget.dart';
import '../../widgets/payment_card/payment_card_selector.dart';
import '../../services/currency_util.dart'; import '../../services/currency_util.dart';
import '../../utils/sms_scan/date_formatter.dart'; import '../../utils/sms_scan/date_formatter.dart';
import '../../utils/sms_scan/category_icon_mapper.dart'; import '../../utils/sms_scan/category_icon_mapper.dart';
@@ -17,20 +19,41 @@ import '../../l10n/app_localizations.dart';
class SubscriptionCardWidget extends StatefulWidget { class SubscriptionCardWidget extends StatefulWidget {
final Subscription subscription; final Subscription subscription;
final TextEditingController serviceNameController;
final TextEditingController websiteUrlController; final TextEditingController websiteUrlController;
final String? selectedCategoryId; final String? selectedCategoryId;
final Function(String?) onCategoryChanged; final Function(String?) onCategoryChanged;
final String? selectedPaymentCardId;
final Function(String?) onPaymentCardChanged;
final Future<void> Function()? onAddCard;
final VoidCallback? onManageCards;
final VoidCallback onAdd; final VoidCallback onAdd;
final VoidCallback onSkip; final VoidCallback onSkip;
final PaymentCardSuggestion? detectedCardSuggestion;
final bool showDetectedCardShortcut;
final Future<void> Function(PaymentCardSuggestion suggestion)?
onAddDetectedCard;
final bool enableServiceNameEditing;
final ValueChanged<String>? onServiceNameChanged;
const SubscriptionCardWidget({ const SubscriptionCardWidget({
super.key, super.key,
required this.subscription, required this.subscription,
required this.serviceNameController,
required this.websiteUrlController, required this.websiteUrlController,
this.selectedCategoryId, this.selectedCategoryId,
required this.onCategoryChanged, required this.onCategoryChanged,
required this.selectedPaymentCardId,
required this.onPaymentCardChanged,
this.onAddCard,
this.onManageCards,
required this.onAdd, required this.onAdd,
required this.onSkip, required this.onSkip,
this.detectedCardSuggestion,
this.showDetectedCardShortcut = false,
this.onAddDetectedCard,
this.enableServiceNameEditing = false,
this.onServiceNameChanged,
}); });
@override @override
@@ -67,6 +90,12 @@ class _SubscriptionCardWidgetState extends State<SubscriptionCardWidget> {
// 광고 위젯 추가 // 광고 위젯 추가
const NativeAdWidget(key: ValueKey('sms_scan_result_ad')), const NativeAdWidget(key: ValueKey('sms_scan_result_ad')),
const SizedBox(height: 16), const SizedBox(height: 16),
if (_hasRawSmsMessage)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: _buildSmsPreviewCard(context),
),
if (_hasRawSmsMessage) const SizedBox(height: 16),
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0), padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Column( child: Column(
@@ -126,6 +155,75 @@ class _SubscriptionCardWidgetState extends State<SubscriptionCardWidget> {
); );
} }
bool get _hasRawSmsMessage {
return widget.subscription.rawMessage != null &&
widget.subscription.rawMessage!.trim().isNotEmpty;
}
Widget _buildSmsPreviewCard(BuildContext context) {
final theme = Theme.of(context);
final loc = AppLocalizations.of(context);
final rawMessage = widget.subscription.rawMessage?.trim() ?? '';
final lastDate = widget.subscription.lastPaymentDate;
return Card(
elevation: 0,
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.4),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.sms_outlined, color: theme.colorScheme.primary),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ThemedText(
loc.latestSmsMessage,
fontWeight: FontWeight.bold,
),
if (lastDate != null)
ThemedText(
loc.smsDetectedDate(loc.formatDate(lastDate)),
opacity: 0.7,
fontSize: 13,
),
],
),
),
],
),
const SizedBox(height: 12),
Container(
width: double.infinity,
decoration: BoxDecoration(
color: theme.colorScheme.surface,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: theme.colorScheme.outline.withValues(alpha: 0.3),
),
),
padding: const EdgeInsets.all(12),
child: SelectableText(
rawMessage,
style: TextStyle(
fontSize: 15,
height: 1.4,
color: theme.colorScheme.onSurface,
),
),
),
],
),
),
);
}
// 정보 섹션 (클릭 가능) // 정보 섹션 (클릭 가능)
Widget _buildInfoSection(CategoryProvider categoryProvider) { Widget _buildInfoSection(CategoryProvider categoryProvider) {
return Column( return Column(
@@ -145,6 +243,15 @@ class _SubscriptionCardWidgetState extends State<SubscriptionCardWidget> {
opacity: 0.7, opacity: 0.7,
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
if (widget.enableServiceNameEditing)
BaseTextField(
controller: widget.serviceNameController,
hintText: AppLocalizations.of(context).serviceNameRequired,
onChanged: widget.onServiceNameChanged,
textInputAction: TextInputAction.done,
maxLines: 1,
)
else
ThemedText( ThemedText(
widget.subscription.serviceName, widget.subscription.serviceName,
fontSize: 22, fontSize: 22,
@@ -246,6 +353,39 @@ class _SubscriptionCardWidgetState extends State<SubscriptionCardWidget> {
), ),
const SizedBox(height: 24), 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 입력 필드 // 웹사이트 URL 입력 필드
BaseTextField( BaseTextField(
controller: widget.websiteUrlController, controller: widget.websiteUrlController,
@@ -297,3 +437,84 @@ class _SubscriptionCardWidgetState extends State<SubscriptionCardWidget> {
return CategoryIconMapper.getCategoryColor(category); return CategoryIconMapper.getCategoryColor(category);
} }
} }
class _DetectedCardSuggestionBanner extends StatelessWidget {
final PaymentCardSuggestion suggestion;
final Future<void> 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),
),
],
),
);
}
}

View File

@@ -2,10 +2,12 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../models/subscription_model.dart'; import '../models/subscription_model.dart';
import '../providers/category_provider.dart'; import '../providers/category_provider.dart';
import '../providers/payment_card_provider.dart';
import '../providers/locale_provider.dart'; import '../providers/locale_provider.dart';
import '../services/subscription_url_matcher.dart'; import '../services/subscription_url_matcher.dart';
import '../services/currency_util.dart'; import '../services/currency_util.dart';
import '../utils/billing_date_util.dart'; import '../utils/billing_date_util.dart';
import '../utils/payment_card_utils.dart';
import 'website_icon.dart'; import 'website_icon.dart';
import 'app_navigator.dart'; import 'app_navigator.dart';
// import '../theme/app_colors.dart'; // import '../theme/app_colors.dart';
@@ -299,6 +301,7 @@ class _SubscriptionCardState extends State<SubscriptionCard>
Widget build(BuildContext context) { Widget build(BuildContext context) {
// LocaleProvider를 watch하여 언어 변경시 자동 업데이트 // LocaleProvider를 watch하여 언어 변경시 자동 업데이트
final localeProvider = context.watch<LocaleProvider>(); final localeProvider = context.watch<LocaleProvider>();
final paymentCardProvider = context.watch<PaymentCardProvider>();
// 언어가 변경되면 displayName 다시 로드 // 언어가 변경되면 displayName 다시 로드
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
@@ -464,26 +467,34 @@ class _SubscriptionCardState extends State<SubscriptionCard>
], ],
), ),
const SizedBox(height: 6), const SizedBox(height: 8),
_buildPaymentCardBadge(
context, paymentCardProvider),
const SizedBox(height: 8),
// 가격 정보 // 가격 정보
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// 가격 표시 (이벤트 가격 반영) // 가격 표시 (이벤트 가격 반영)
// 가격 표시 (언어별 통화) Expanded(
FutureBuilder<String>( child: FutureBuilder<String>(
future: _getFormattedPrice(), future: _getFormattedPrice(),
builder: (context, snapshot) { builder: (context, snapshot) {
if (!snapshot.hasData) { if (!snapshot.hasData) {
return const SizedBox(); return const SizedBox();
} }
if (widget if (widget.subscription
.subscription.isCurrentlyInEvent && .isCurrentlyInEvent &&
snapshot.data!.contains('|')) { snapshot.data!.contains('|')) {
final prices = snapshot.data!.split('|'); final prices =
return Row( snapshot.data!.split('|');
return Wrap(
spacing: 8,
runSpacing: 4,
crossAxisAlignment:
WrapCrossAlignment.center,
children: [ children: [
Text( Text(
prices[0], prices[0],
@@ -497,7 +508,6 @@ class _SubscriptionCardState extends State<SubscriptionCard>
TextDecoration.lineThrough, TextDecoration.lineThrough,
), ),
), ),
const SizedBox(width: 8),
Text( Text(
prices[1], prices[1],
style: TextStyle( style: TextStyle(
@@ -513,6 +523,8 @@ class _SubscriptionCardState extends State<SubscriptionCard>
} else { } else {
return Text( return Text(
snapshot.data!, snapshot.data!,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle( style: TextStyle(
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
@@ -529,6 +541,8 @@ class _SubscriptionCardState extends State<SubscriptionCard>
} }
}, },
), ),
),
const SizedBox(width: 12),
// 결제 예정일 정보 // 결제 예정일 정보
Container( Container(
@@ -673,4 +687,63 @@ class _SubscriptionCardState extends State<SubscriptionCard>
), ),
); );
} }
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: Container(
width: 20,
height: 20,
decoration: BoxDecoration(
color: color.withValues(alpha: 0.15),
shape: BoxShape.circle,
),
alignment: Alignment.center,
child: Icon(
icon,
size: 12,
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,
);
}
} }

View File

@@ -0,0 +1,171 @@
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(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
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,
),
),
],
),
),
],
),
),
const SizedBox(width: 12),
Flexible(
child: Text(
_buildCostDisplay(context),
textAlign: TextAlign.end,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: scheme.onSurfaceVariant,
),
),
),
],
),
const SizedBox(height: 8),
Divider(
height: 1,
thickness: 1,
color: scheme.outline.withValues(alpha: 0.3),
),
],
),
);
}
String _buildCostDisplay(BuildContext context) {
final parts = <String>[];
parts
.add(AppLocalizations.of(context).subscriptionCount(subscriptionCount));
final currencyParts = <String>[];
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,
),
);
}
}

View File

@@ -1,66 +1,52 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../models/subscription_model.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/swipeable_subscription_card.dart';
import '../widgets/staggered_list_animation.dart'; import '../widgets/staggered_list_animation.dart';
import '../widgets/app_navigator.dart'; import '../widgets/app_navigator.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../providers/subscription_provider.dart'; import '../providers/subscription_provider.dart';
import '../providers/locale_provider.dart'; import '../providers/locale_provider.dart';
import '../providers/category_provider.dart';
import '../services/subscription_url_matcher.dart'; import '../services/subscription_url_matcher.dart';
import './dialogs/delete_confirmation_dialog.dart'; import './dialogs/delete_confirmation_dialog.dart';
import './common/snackbar/app_snackbar.dart'; import './common/snackbar/app_snackbar.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
import '../utils/logger.dart'; import '../utils/logger.dart';
import '../utils/subscription_grouping_helper.dart';
/// 카테고리별로 구독 목록을 표시하는 위젯 /// 카테고리별로 구독 목록을 표시하는 위젯
class SubscriptionListWidget extends StatelessWidget { class SubscriptionListWidget extends StatelessWidget {
final Map<String, List<SubscriptionModel>> categorizedSubscriptions; final List<SubscriptionGroupData> groups;
final AnimationController fadeController; final AnimationController fadeController;
const SubscriptionListWidget({ const SubscriptionListWidget({
super.key, super.key,
required this.categorizedSubscriptions, required this.groups,
required this.fadeController, required this.fadeController,
}); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// 카테고리 키 목록 (정렬된) final sections = groups;
final categories = categorizedSubscriptions.keys.toList();
return SliverList( return SliverList(
delegate: SliverChildBuilderDelegate( delegate: SliverChildBuilderDelegate(
(context, index) { (context, index) {
final category = categories[index]; final group = sections[index];
final subscriptions = categorizedSubscriptions[category]!; final subscriptions = group.subscriptions;
return Padding( return Padding(
padding: const EdgeInsets.only(bottom: 8.0), padding: const EdgeInsets.only(bottom: 8.0),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// 카테고리 헤더 SubscriptionGroupHeader(
Padding( group: group,
padding: const EdgeInsets.fromLTRB(20, 16, 20, 8),
child: Consumer<CategoryProvider>(
builder: (context, categoryProvider, child) {
return CategoryHeaderWidget(
categoryName: categoryProvider.getLocalizedCategoryName(
context, category),
subscriptionCount: subscriptions.length, subscriptionCount: subscriptions.length,
totalCostUSD: totalCostUSD: _calculateTotalByCurrency(subscriptions, 'USD'),
_calculateTotalByCurrency(subscriptions, 'USD'), totalCostKRW: _calculateTotalByCurrency(subscriptions, 'KRW'),
totalCostKRW: totalCostJPY: _calculateTotalByCurrency(subscriptions, 'JPY'),
_calculateTotalByCurrency(subscriptions, 'KRW'), totalCostCNY: _calculateTotalByCurrency(subscriptions, 'CNY'),
totalCostJPY:
_calculateTotalByCurrency(subscriptions, 'JPY'),
totalCostCNY:
_calculateTotalByCurrency(subscriptions, 'CNY'),
);
},
),
), ),
// 카테고리별 구독 목록 // 카테고리별 구독 목록
FadeTransition( FadeTransition(
@@ -72,7 +58,6 @@ class SubscriptionListWidget extends StatelessWidget {
shrinkWrap: true, shrinkWrap: true,
padding: const EdgeInsets.symmetric(horizontal: 16), padding: const EdgeInsets.symmetric(horizontal: 16),
cacheExtent: 500, cacheExtent: 500,
prototypeItem: const SizedBox(height: 156),
itemCount: subscriptions.length, itemCount: subscriptions.length,
itemBuilder: (context, subIndex) { itemBuilder: (context, subIndex) {
// 각 구독의 지연값 계산 (순차적으로 나타나도록) // 각 구독의 지연값 계산 (순차적으로 나타나도록)
@@ -169,7 +154,7 @@ class SubscriptionListWidget extends StatelessWidget {
), ),
); );
}, },
childCount: categories.length, childCount: sections.length,
), ),
); );
} }