feat: add payment card grouping and analysis

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

View File

@@ -29,6 +29,29 @@
"language": "Language",
"notifications": "Notifications",
"appLock": "App Lock",
"paymentCard": "Payment Card",
"paymentCardManagement": "Payment Card Management",
"paymentCardManagementDescription": "Manage saved cards for subscriptions",
"addPaymentCard": "Add Payment Card",
"editPaymentCard": "Edit Payment Card",
"paymentCardIssuer": "Card Name / Issuer",
"paymentCardLast4": "Last 4 Digits",
"paymentCardColor": "Card Color",
"paymentCardIcon": "Card Icon",
"setAsDefaultCard": "Set as default card",
"paymentCardUnassigned": "Unassigned",
"addNewCard": "Add New Card",
"managePaymentCards": "Manage Cards",
"choosePaymentCard": "Choose Payment Card",
"analysisCardFilterLabel": "Filter by payment card",
"analysisCardFilterAll": "All cards",
"cardDefaultBadge": "Default",
"noPaymentCards": "No payment cards saved yet.",
"detectedPaymentCard": "Card Detected",
"detectedPaymentCardDescription": "@ was detected from SMS.",
"addDetectedPaymentCard": "Add Card",
"paymentCardUnassignedWarning": "Without a card selection this subscription will be saved as \"Unassigned\".",
"areYouSure": "Are you sure?",
"notificationPermission": "Notification Permission",
"notificationPermissionDesc": "Permission is required to receive notifications",
"requestPermission": "Request Permission",
@@ -260,6 +283,29 @@
"language": "언어",
"notifications": "알림",
"appLock": "앱 잠금",
"paymentCard": "결제수단",
"paymentCardManagement": "결제수단 관리",
"paymentCardManagementDescription": "저장된 결제수단을 추가·편집·삭제합니다",
"addPaymentCard": "결제수단 추가",
"editPaymentCard": "결제수단 수정",
"paymentCardIssuer": "카드 이름 / 발급사",
"paymentCardLast4": "마지막 4자리",
"paymentCardColor": "카드 색상",
"paymentCardIcon": "아이콘",
"setAsDefaultCard": "기본 결제수단으로 설정",
"paymentCardUnassigned": "미지정",
"addNewCard": "새 카드 추가",
"managePaymentCards": "결제수단 관리",
"choosePaymentCard": "결제수단 선택",
"analysisCardFilterLabel": "결제수단별 보기",
"analysisCardFilterAll": "모든 결제수단",
"cardDefaultBadge": "기본",
"noPaymentCards": "등록된 결제수단이 없습니다.",
"detectedPaymentCard": "감지된 결제수단",
"detectedPaymentCardDescription": "SMS에서 @ 이(가) 감지되었습니다.",
"addDetectedPaymentCard": "카드 추가",
"paymentCardUnassignedWarning": "결제수단을 선택하지 않으면 '미지정'으로 저장됩니다.",
"areYouSure": "정말 진행하시겠어요?",
"notificationPermission": "알림 권한",
"notificationPermissionDesc": "알림을 받으려면 권한이 필요합니다",
"requestPermission": "권한 요청",
@@ -491,6 +537,29 @@
"language": "言語",
"notifications": "通知",
"appLock": "アプリロック",
"paymentCard": "支払いカード",
"paymentCardManagement": "支払いカード管理",
"paymentCardManagementDescription": "保存済みのカードを追加・編集・削除します",
"addPaymentCard": "カードを追加",
"editPaymentCard": "カードを編集",
"paymentCardIssuer": "カード名 / 発行会社",
"paymentCardLast4": "下4桁",
"paymentCardColor": "カードカラー",
"paymentCardIcon": "アイコン",
"setAsDefaultCard": "既定のカードとして設定",
"paymentCardUnassigned": "未設定",
"addNewCard": "新しいカードを追加",
"managePaymentCards": "カードを管理",
"choosePaymentCard": "支払いカードを選択",
"analysisCardFilterLabel": "支払いカード別に表示",
"analysisCardFilterAll": "すべてのカード",
"cardDefaultBadge": "既定",
"noPaymentCards": "登録されたカードがありません。",
"detectedPaymentCard": "検出されたカード",
"detectedPaymentCardDescription": "SMS から @ が検出されました。",
"addDetectedPaymentCard": "カードを追加",
"paymentCardUnassignedWarning": "カードを選択しない場合は「未設定」として保存されます。",
"areYouSure": "よろしいですか?",
"notificationPermission": "通知権限",
"notificationPermissionDesc": "通知を受け取るには権限が必要です",
"requestPermission": "権限をリクエスト",
@@ -711,6 +780,29 @@
"language": "语言",
"notifications": "通知",
"appLock": "应用锁定",
"paymentCard": "支付卡",
"paymentCardManagement": "支付卡管理",
"paymentCardManagementDescription": "管理已保存的支付卡(新增/编辑/删除)",
"addPaymentCard": "添加支付卡",
"editPaymentCard": "编辑支付卡",
"paymentCardIssuer": "卡名称/发卡行",
"paymentCardLast4": "后四位",
"paymentCardColor": "卡片颜色",
"paymentCardIcon": "图标",
"setAsDefaultCard": "设为默认卡",
"paymentCardUnassigned": "未指定",
"addNewCard": "新增卡片",
"managePaymentCards": "管理卡片",
"choosePaymentCard": "选择支付卡",
"analysisCardFilterLabel": "按支付卡筛选",
"analysisCardFilterAll": "所有支付卡",
"cardDefaultBadge": "默认",
"noPaymentCards": "尚未保存任何支付卡。",
"detectedPaymentCard": "检测到的支付卡",
"detectedPaymentCardDescription": "短信检测到 @。",
"addDetectedPaymentCard": "添加卡片",
"paymentCardUnassignedWarning": "未选择支付卡时将以\"未指定\"保存。",
"areYouSure": "确定要继续吗?",
"notificationPermission": "通知权限",
"notificationPermissionDesc": "需要权限才能接收通知",
"requestPermission": "请求权限",

View File

@@ -10,6 +10,16 @@
- 홈 화면에서 카테고리/카드 뷰를 토글하며, 헤더·리스트·분석 카드가 카드 정보를 시각적으로 노출.
- 모든 변경은 기존 카테고리 UX를 유지하면서 점진적 도입이 가능해야 함.
## 작업 체크리스트
1. [x] `SubscriptionModel``paymentCardId` 추가, `PaymentCardModel`/`PaymentCardProvider` 구현, Hive 등록 및 마이그레이션 수행.
2. [x] 결제수단 관리 화면과 구독 추가/편집/SMS 시트에서 사용할 `PaymentCardSelector` + “새 카드 추가” 플로우 구현.
3. [x] 홈·리스트 UI에 카테고리/카드 토글, 그룹 헤더, 카드 Chip 표시, `SubscriptionGroupingHelper` 도입.
4. [x] 구독 상세 화면(헤더, 결제 정보 섹션, 편집 폼)에서 카드 정보 노출 및 수정 기능 연결.
5. [x] SMS 스캔 컨트롤러/리뷰 UI에 카드 추정·선택·저장 로직 추가, 기본값/자동 생성 전략 반영.
6. [x] 분석 화면(파이 차트, 합계 카드, 월별 차트 등)이 카드 필터/데이터에 대응하도록 확장.
7. [x] 설정/내비게이션/로컬라이제이션/접근성 업데이트 및 새 문자열 번역 반영.
8. [ ] QA 플로우: `flutter pub run build_runner build`, `scripts/check.sh`, 다국어·다크모드·태블릿·알림/백업 테스트 완료.
## 데이터 모델 및 저장소
- `SubscriptionModel``paymentCardId`(필요 시 `displayName`/닉네임) 필드 추가, Hive 어댑터 재생성. 기존 데이터는 `null` 기본값으로 역호환.
-`PaymentCardModel` 작성: `id`, `issuerName`(회사명 자유 입력), `last4`, `colorHex`, `iconName` 등을 저장. Hive typeId는 미사용 값을 배정.
@@ -91,6 +101,12 @@
4. 다국어·다크모드·태블릿 해상도에서 UI 붕괴 여부 점검.
5. 알림/위젯/백그라운드 서비스(예: 결제 알림)에서 카드 필드 추가로 인한 크래시가 없는지 Crashlytics/디버그 로그 확인.
#### QA 실행 현황 (2025-11-14)
-`flutter pub run build_runner build --delete-conflicting-outputs`
-`scripts/check.sh`
-`flutter analyze`
-`flutter test`
## 분석 및 향후 확장
- 공통 `SubscriptionGroupingHelper`를 만들어 카드/카테고리 그룹 데이터를 모두 생성하게 설계하면 `MonthlyExpenseChartCard`, 파이 차트, 이벤트 카드 등도 카드 필터를 쉽게 지원.
- 초기에는 홈 리스트에만 카드 모드를 적용하고, 이후 분석 탭에 “카드별 지출” 섹션을 추가해 순차 배포.

View File

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

View File

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

View File

@@ -1,8 +1,10 @@
import 'package:flutter/material.dart';
import '../services/sms_scanner.dart';
import '../models/subscription.dart';
import '../models/payment_card_suggestion.dart';
import '../services/sms_scan/subscription_converter.dart';
import '../services/sms_scan/subscription_filter.dart';
import '../services/sms_scan/sms_scan_result.dart';
import '../providers/subscription_provider.dart';
import 'package:provider/provider.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
@@ -11,6 +13,7 @@ import '../utils/logger.dart';
import '../providers/navigation_provider.dart';
import '../providers/category_provider.dart';
import '../l10n/app_localizations.dart';
import '../providers/payment_card_provider.dart';
class SmsScanController extends ChangeNotifier {
// 상태 관리
@@ -22,12 +25,18 @@ class SmsScanController extends ChangeNotifier {
List<Subscription> _scannedSubscriptions = [];
List<Subscription> get scannedSubscriptions => _scannedSubscriptions;
PaymentCardSuggestion? _currentSuggestion;
PaymentCardSuggestion? get currentSuggestion => _currentSuggestion;
bool _shouldSuggestCardCreation = false;
bool get shouldSuggestCardCreation => _shouldSuggestCardCreation;
int _currentIndex = 0;
int get currentIndex => _currentIndex;
String? _selectedCategoryId;
String? get selectedCategoryId => _selectedCategoryId;
String? _selectedPaymentCardId;
String? get selectedPaymentCardId => _selectedPaymentCardId;
final TextEditingController websiteUrlController = TextEditingController();
@@ -47,6 +56,14 @@ class SmsScanController extends ChangeNotifier {
notifyListeners();
}
void setSelectedPaymentCardId(String? paymentCardId) {
_selectedPaymentCardId = paymentCardId;
if (paymentCardId != null) {
_shouldSuggestCardCreation = false;
}
notifyListeners();
}
void resetWebsiteUrl() {
websiteUrlController.text = '';
}
@@ -88,18 +105,18 @@ class SmsScanController extends ChangeNotifier {
// SMS 스캔 실행
Log.i('SMS 스캔 시작');
final scannedSubscriptionModels =
final List<SmsScanResult> scanResults =
await _smsScanner.scanForSubscriptions();
Log.d('스캔된 구독: ${scannedSubscriptionModels.length}');
Log.d('스캔된 구독: ${scanResults.length}');
if (scannedSubscriptionModels.isNotEmpty) {
if (scanResults.isNotEmpty) {
Log.d(
'첫 번째 구독: ${scannedSubscriptionModels[0].serviceName}, 반복 횟수: ${scannedSubscriptionModels[0].repeatCount}');
'첫 번째 구독: ${scanResults[0].model.serviceName}, 반복 횟수: ${scanResults[0].model.repeatCount}');
}
if (!context.mounted) return;
if (scannedSubscriptionModels.isEmpty) {
if (scanResults.isEmpty) {
Log.i('스캔된 구독이 없음');
_errorMessage = AppLocalizations.of(context).subscriptionNotFound;
_isLoading = false;
@@ -109,7 +126,7 @@ class SmsScanController extends ChangeNotifier {
// SubscriptionModel을 Subscription으로 변환
final scannedSubscriptions =
_converter.convertModelsToSubscriptions(scannedSubscriptionModels);
_converter.convertResultsToSubscriptions(scanResults);
// 2회 이상 반복 결제된 구독만 필터링
final repeatSubscriptions =
@@ -155,7 +172,9 @@ class SmsScanController extends ChangeNotifier {
_scannedSubscriptions = filteredSubscriptions;
_isLoading = false;
websiteUrlController.text = ''; // URL 입력 필드 초기화
websiteUrlController.text = '';
_currentSuggestion = null;
_prepareCurrentSelection(context);
notifyListeners();
} catch (e) {
Log.e('SMS 스캔 중 오류 발생', e);
@@ -202,10 +221,14 @@ class SmsScanController extends ChangeNotifier {
Provider.of<SubscriptionProvider>(context, listen: false);
final categoryProvider =
Provider.of<CategoryProvider>(context, listen: false);
final paymentCardProvider =
Provider.of<PaymentCardProvider>(context, listen: false);
final finalCategoryId = _selectedCategoryId ??
subscription.category ??
getDefaultCategoryId(categoryProvider);
final finalPaymentCardId =
_selectedPaymentCardId ?? paymentCardProvider.defaultCard?.id;
// websiteUrl 처리
final websiteUrl = websiteUrlController.text.trim().isNotEmpty
@@ -226,6 +249,7 @@ class SmsScanController extends ChangeNotifier {
repeatCount: subscription.repeatCount,
lastPaymentDate: subscription.lastPaymentDate,
categoryId: finalCategoryId,
paymentCardId: finalPaymentCardId,
currency: subscription.currency,
);
@@ -248,8 +272,9 @@ class SmsScanController extends ChangeNotifier {
void moveToNextSubscription(BuildContext context) {
_currentIndex++;
websiteUrlController.text = ''; // URL 입력 필드 초기화
_selectedCategoryId = null; // 카테고리 선택 초기화
websiteUrlController.text = '';
_selectedCategoryId = null;
_prepareCurrentSelection(context);
// 모든 구독을 처리했으면 홈 화면으로 이동
if (_currentIndex >= _scannedSubscriptions.length) {
@@ -270,6 +295,9 @@ class SmsScanController extends ChangeNotifier {
_scannedSubscriptions = [];
_currentIndex = 0;
_errorMessage = null;
_selectedPaymentCardId = null;
_currentSuggestion = null;
_shouldSuggestCardCreation = false;
notifyListeners();
}
@@ -290,4 +318,78 @@ class SmsScanController extends ChangeNotifier {
}
}
}
String? _getDefaultPaymentCardId(BuildContext context) {
try {
final provider = Provider.of<PaymentCardProvider>(context, listen: false);
return provider.defaultCard?.id;
} catch (_) {
return null;
}
}
void _prepareCurrentSelection(BuildContext context) {
if (_currentIndex >= _scannedSubscriptions.length) {
_selectedPaymentCardId = null;
_currentSuggestion = null;
return;
}
final current = _scannedSubscriptions[_currentIndex];
// URL 기본값
if (current.websiteUrl != null && current.websiteUrl!.isNotEmpty) {
websiteUrlController.text = current.websiteUrl!;
} else {
websiteUrlController.clear();
}
_currentSuggestion = current.paymentCardSuggestion;
final matchedCardId = _matchCardWithSuggestion(context, _currentSuggestion);
_shouldSuggestCardCreation =
_currentSuggestion != null && matchedCardId == null;
if (matchedCardId != null) {
_selectedPaymentCardId = matchedCardId;
return;
}
// 모델에 직접 카드 정보가 존재하면 우선 사용
if (current.paymentCardId != null) {
_selectedPaymentCardId = current.paymentCardId;
return;
}
_selectedPaymentCardId = _getDefaultPaymentCardId(context);
}
String? _matchCardWithSuggestion(
BuildContext context, PaymentCardSuggestion? suggestion) {
if (suggestion == null) return null;
try {
final provider = Provider.of<PaymentCardProvider>(context, listen: false);
final cards = provider.cards;
if (cards.isEmpty) return null;
if (suggestion.hasLast4) {
for (final card in cards) {
if (card.last4 == suggestion.last4) {
return card.id;
}
}
}
final normalizedIssuer = suggestion.issuerName.toLowerCase();
for (final card in cards) {
final issuer = card.issuerName.toLowerCase();
if (issuer.contains(normalizedIssuer) ||
normalizedIssuer.contains(issuer)) {
return card.id;
}
}
} catch (_) {
return null;
}
return null;
}
}

View File

@@ -63,6 +63,56 @@ class AppLocalizations {
String get notifications =>
_localizedStrings['notifications'] ?? 'Notifications';
String get appLock => _localizedStrings['appLock'] ?? 'App Lock';
String get paymentCard => _localizedStrings['paymentCard'] ?? 'Payment Card';
String get paymentCardManagement =>
_localizedStrings['paymentCardManagement'] ?? 'Payment Card Management';
String get paymentCardManagementDescription =>
_localizedStrings['paymentCardManagementDescription'] ??
'Manage saved cards for subscriptions';
String get addPaymentCard =>
_localizedStrings['addPaymentCard'] ?? 'Add Payment Card';
String get editPaymentCard =>
_localizedStrings['editPaymentCard'] ?? 'Edit Payment Card';
String get paymentCardIssuer =>
_localizedStrings['paymentCardIssuer'] ?? 'Card Name / Issuer';
String get paymentCardLast4 =>
_localizedStrings['paymentCardLast4'] ?? 'Last 4 Digits';
String get paymentCardColor =>
_localizedStrings['paymentCardColor'] ?? 'Card Color';
String get paymentCardIcon =>
_localizedStrings['paymentCardIcon'] ?? 'Card Icon';
String get setAsDefaultCard =>
_localizedStrings['setAsDefaultCard'] ?? 'Set as default card';
String get paymentCardUnassigned =>
_localizedStrings['paymentCardUnassigned'] ?? 'Unassigned';
String get detectedPaymentCard =>
_localizedStrings['detectedPaymentCard'] ?? 'Card detected';
String detectedPaymentCardDescription(String issuer, String last4) {
final template = _localizedStrings['detectedPaymentCardDescription'] ??
'@ was detected from SMS.';
final label = last4.isNotEmpty ? '$issuer · ****$last4' : issuer;
return template.replaceAll('@', label);
}
String get addDetectedPaymentCard =>
_localizedStrings['addDetectedPaymentCard'] ?? 'Add card';
String get paymentCardUnassignedWarning =>
_localizedStrings['paymentCardUnassignedWarning'] ??
'Without a card selection this subscription will be saved as "Unassigned".';
String get addNewCard => _localizedStrings['addNewCard'] ?? 'Add New Card';
String get managePaymentCards =>
_localizedStrings['managePaymentCards'] ?? 'Manage Cards';
String get choosePaymentCard =>
_localizedStrings['choosePaymentCard'] ?? 'Choose Payment Card';
String get analysisCardFilterLabel =>
_localizedStrings['analysisCardFilterLabel'] ?? 'Filter by payment card';
String get analysisCardFilterAll =>
_localizedStrings['analysisCardFilterAll'] ?? 'All cards';
String get cardDefaultBadge =>
_localizedStrings['cardDefaultBadge'] ?? 'Default';
String get noPaymentCards =>
_localizedStrings['noPaymentCards'] ?? 'No payment cards saved yet.';
String get areYouSure => _localizedStrings['areYouSure'] ?? 'Are you sure?';
// SMS 권한 온보딩/설정
String get smsPermissionTitle =>
_localizedStrings['smsPermissionTitle'] ?? 'Request SMS Permission';

View File

@@ -5,10 +5,12 @@ import 'package:flutter/foundation.dart' show kIsWeb, kDebugMode;
import 'package:flutter_localizations/flutter_localizations.dart';
import 'models/subscription_model.dart';
import 'models/category_model.dart';
import 'models/payment_card_model.dart';
import 'providers/subscription_provider.dart';
import 'providers/app_lock_provider.dart';
import 'providers/notification_provider.dart';
import 'providers/navigation_provider.dart';
import 'providers/payment_card_provider.dart';
import 'services/notification_service.dart';
import 'providers/category_provider.dart';
import 'providers/locale_provider.dart';
@@ -69,14 +71,17 @@ Future<void> main() async {
await Hive.initFlutter();
Hive.registerAdapter(SubscriptionModelAdapter());
Hive.registerAdapter(CategoryModelAdapter());
Hive.registerAdapter(PaymentCardModelAdapter());
await Hive.openBox<SubscriptionModel>('subscriptions');
await Hive.openBox<CategoryModel>('categories');
await Hive.openBox<PaymentCardModel>('payment_cards');
final appLockBox = await Hive.openBox<bool>('app_lock');
// 알림 서비스를 가장 먼저 초기화
await NotificationService.init();
final subscriptionProvider = SubscriptionProvider();
final categoryProvider = CategoryProvider();
final paymentCardProvider = PaymentCardProvider();
final localeProvider = LocaleProvider();
final notificationProvider = NotificationProvider();
final themeProvider = ThemeProvider();
@@ -84,6 +89,7 @@ Future<void> main() async {
await subscriptionProvider.init();
await categoryProvider.init();
await paymentCardProvider.init();
await localeProvider.init();
await notificationProvider.init();
await themeProvider.initialize();
@@ -110,6 +116,7 @@ Future<void> main() async {
providers: [
ChangeNotifierProvider(create: (_) => subscriptionProvider),
ChangeNotifierProvider(create: (_) => categoryProvider),
ChangeNotifierProvider(create: (_) => paymentCardProvider),
ChangeNotifierProvider(create: (_) => AppLockProvider(appLockBox)),
ChangeNotifierProvider(create: (_) => notificationProvider),
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 {
final String id;
final String serviceName;
@@ -10,6 +12,8 @@ class Subscription {
final DateTime? lastPaymentDate;
final String? websiteUrl;
final String currency;
final String? paymentCardId;
final PaymentCardSuggestion? paymentCardSuggestion;
Subscription({
required this.id,
@@ -23,6 +27,8 @@ class Subscription {
this.lastPaymentDate,
this.websiteUrl,
this.currency = 'KRW',
this.paymentCardId,
this.paymentCardSuggestion,
});
Map<String, dynamic> toMap() {
@@ -38,6 +44,10 @@ class Subscription {
'lastPaymentDate': lastPaymentDate?.toIso8601String(),
'websiteUrl': websiteUrl,
'currency': currency,
'paymentCardId': paymentCardId,
'paymentCardSuggestionIssuer': paymentCardSuggestion?.issuerName,
'paymentCardSuggestionLast4': paymentCardSuggestion?.last4,
'paymentCardSuggestionSource': paymentCardSuggestion?.source,
};
}
@@ -56,6 +66,14 @@ class Subscription {
: null,
websiteUrl: map['websiteUrl'] as String?,
currency: map['currency'] as String? ?? 'KRW',
paymentCardId: map['paymentCardId'] as String?,
paymentCardSuggestion: map['paymentCardSuggestionIssuer'] != null
? PaymentCardSuggestion(
issuerName: map['paymentCardSuggestionIssuer'] as String,
last4: map['paymentCardSuggestionLast4'] as String?,
source: map['paymentCardSuggestionSource'] as String?,
)
: null,
);
}

View File

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

View File

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

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

View File

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

View File

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

View File

@@ -3,6 +3,7 @@ import 'package:provider/provider.dart';
import '../models/subscription_model.dart';
import '../controllers/detail_screen_controller.dart';
import '../widgets/detail/detail_header_section.dart';
import '../widgets/detail/detail_payment_info_section.dart';
import '../widgets/detail/detail_form_section.dart';
import '../widgets/detail/detail_event_section.dart';
import '../widgets/detail/detail_url_section.dart';
@@ -120,6 +121,13 @@ class _DetailScreenState extends State<DetailScreen>
),
const SizedBox(height: 16),
DetailPaymentInfoSection(
controller: _controller,
fadeAnimation: _controller.fadeAnimation!,
slideAnimation: _controller.slideAnimation!,
),
const SizedBox(height: 16),
// 기본 정보 폼 섹션
DetailFormSection(
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 '../widgets/common/layout/page_container.dart';
import '../theme/color_scheme_ext.dart';
import '../widgets/app_navigator.dart';
class SettingsScreen extends StatelessWidget {
const SettingsScreen({super.key});
@@ -79,6 +80,7 @@ class SettingsScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
final loc = AppLocalizations.of(context);
return Column(
children: [
Expanded(
@@ -99,6 +101,48 @@ class SettingsScreen extends StatelessWidget {
const SizedBox(height: 16),
// 테마 모드 설정
Card(
margin:
const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
elevation: 1,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(
color: Theme.of(context)
.colorScheme
.outline
.withValues(alpha: 0.5),
),
),
child: Semantics(
button: true,
label: loc.paymentCardManagement,
hint: loc.paymentCardManagementDescription,
child: ListTile(
leading: Icon(
Icons.credit_card,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
title: Text(
loc.paymentCardManagement,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface,
),
),
subtitle: Text(
loc.paymentCardManagementDescription,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
trailing: Icon(Icons.chevron_right_rounded,
color:
Theme.of(context).colorScheme.onSurfaceVariant),
onTap: () =>
AppNavigator.toPaymentCardManagement(context),
),
),
),
Card(
margin:
const EdgeInsets.symmetric(vertical: 8, horizontal: 16),

View File

@@ -6,6 +6,9 @@ import '../widgets/sms_scan/scan_progress_widget.dart';
import '../widgets/sms_scan/subscription_card_widget.dart';
import '../widgets/common/snackbar/app_snackbar.dart';
import '../l10n/app_localizations.dart';
import '../widgets/payment_card/payment_card_form_sheet.dart';
import '../routes/app_routes.dart';
import '../models/payment_card_suggestion.dart';
class SmsScanScreen extends StatefulWidget {
const SmsScanScreen({super.key});
@@ -96,8 +99,23 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
websiteUrlController: _controller.websiteUrlController,
selectedCategoryId: _controller.selectedCategoryId,
onCategoryChanged: _controller.setSelectedCategoryId,
selectedPaymentCardId: _controller.selectedPaymentCardId,
onPaymentCardChanged: _controller.setSelectedPaymentCardId,
onAddCard: () async {
final newCardId = await PaymentCardFormSheet.show(context);
if (newCardId != null) {
_controller.setSelectedPaymentCardId(newCardId);
}
},
onManageCards: () {
Navigator.of(context).pushNamed(AppRoutes.paymentCardManagement);
},
onAdd: _handleAddSubscription,
onSkip: _handleSkipSubscription,
detectedCardSuggestion: _controller.currentSuggestion,
showDetectedCardShortcut: _controller.shouldSuggestCardCreation,
onAddDetectedCard: (suggestion) =>
_handleDetectedCardCreation(suggestion),
),
],
);
@@ -114,6 +132,18 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
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() {
if (!_scrollController.hasClients) return;
_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_model.dart';
import 'sms_scan_result.dart';
class SubscriptionConverter {
// SubscriptionModel 리스트를 Subscription 리스트로 변환
List<Subscription> convertModelsToSubscriptions(
List<SubscriptionModel> models) {
List<Subscription> convertResultsToSubscriptions(
List<SmsScanResult> results) {
final result = <Subscription>[];
for (var model in models) {
for (final smsResult in results) {
try {
final subscription = _convertSingle(model);
final subscription = _convertSingle(smsResult);
result.add(subscription);
// 개발 편의를 위한 디버그 로그
// ignore: avoid_print
print(
'모델 변환 성공: ${model.serviceName}, 카테고리ID: ${model.categoryId}, URL: ${model.websiteUrl}, 통화: ${model.currency}');
'모델 변환 성공: ${smsResult.model.serviceName}, 카테고리ID: ${smsResult.model.categoryId}, URL: ${smsResult.model.websiteUrl}, 통화: ${smsResult.model.currency}');
} catch (e) {
// ignore: avoid_print
print('모델 변환 중 오류 발생: $e');
@@ -26,7 +26,8 @@ class SubscriptionConverter {
}
// 단일 모델 변환
Subscription _convertSingle(SubscriptionModel model) {
Subscription _convertSingle(SmsScanResult result) {
final model = result.model;
return Subscription(
id: model.id,
serviceName: model.serviceName,
@@ -38,6 +39,8 @@ class SubscriptionConverter {
lastPaymentDate: model.lastPaymentDate,
websiteUrl: model.websiteUrl,
currency: model.currency,
paymentCardId: model.paymentCardId,
paymentCardSuggestion: result.cardSuggestion,
);
}

View File

@@ -8,11 +8,13 @@ import '../temp/test_sms_data.dart';
import '../services/subscription_url_matcher.dart';
import '../utils/platform_helper.dart';
import '../utils/business_day_util.dart';
import '../services/sms_scan/sms_scan_result.dart';
import '../models/payment_card_suggestion.dart';
class SmsScanner {
final SmsQuery _query = SmsQuery();
Future<List<SubscriptionModel>> scanForSubscriptions() async {
Future<List<SmsScanResult>> scanForSubscriptions() async {
try {
List<dynamic> smsList;
Log.d('SmsScanner: 스캔 시작');
@@ -39,7 +41,7 @@ class SmsScanner {
}
// SMS 데이터를 분석하여 반복 결제되는 구독 식별
final List<SubscriptionModel> subscriptions = [];
final List<SmsScanResult> subscriptions = [];
final serviceGroups = _groupMessagesByIdentifier(smsList);
Log.d('SmsScanner: 그룹화된 키 수: ${serviceGroups.length}');
@@ -52,12 +54,12 @@ class SmsScanner {
continue;
}
final subscription =
final result =
_parseSms(repeatResult.baseMessage, repeatResult.repeatCount);
if (subscription != null) {
if (result != null) {
Log.i(
'SmsScanner: 구독 추가: ${subscription.serviceName}, 반복 횟수: ${subscription.repeatCount}');
subscriptions.add(subscription);
'SmsScanner: 구독 추가: ${result.model.serviceName}, 반복 횟수: ${result.model.repeatCount}');
subscriptions.add(result);
} else {
Log.w('SmsScanner: 구독 파싱 실패: ${entry.key}');
}
@@ -99,7 +101,7 @@ class SmsScanner {
// (사용되지 않음) 기존 단일 메시지 파서 제거됨 - Isolate 배치 파싱으로 대체
SubscriptionModel? _parseSms(Map<String, dynamic> sms, int repeatCount) {
SmsScanResult? _parseSms(Map<String, dynamic> sms, int repeatCount) {
try {
final serviceName = sms['serviceName'] as String? ?? '알 수 없는 서비스';
final monthlyCost = (sms['monthlyCost'] as num?)?.toDouble() ?? 0.0;
@@ -150,7 +152,7 @@ class SmsScanner {
adjustedNextBillingDate =
BusinessDayUtil.nextBusinessDay(adjustedNextBillingDate);
return SubscriptionModel(
final model = SubscriptionModel(
id: DateTime.now().millisecondsSinceEpoch.toString(),
serviceName: serviceName,
monthlyCost: monthlyCost,
@@ -162,11 +164,84 @@ class SmsScanner {
websiteUrl: _extractWebsiteUrl(serviceName),
currency: currency, // 통화 단위 설정
);
final suggestion = _extractPaymentCardSuggestion(message);
return SmsScanResult(
model: model,
cardSuggestion: suggestion,
rawMessage: message,
);
} catch (e) {
return null;
}
}
PaymentCardSuggestion? _extractPaymentCardSuggestion(String message) {
if (message.isEmpty) return null;
final issuer = _detectCardIssuer(message);
final last4 = _detectCardLast4(message);
if (issuer == null && last4 == null) {
return null;
}
return PaymentCardSuggestion(
issuerName: issuer ?? '결제수단',
last4: last4,
source: 'sms',
);
}
String? _detectCardIssuer(String message) {
final normalized = message.toLowerCase();
const issuerKeywords = {
'KB국민카드': ['kb국민', '국민카드', 'kb card', 'kookmin'],
'신한카드': ['신한', 'shinhan'],
'우리카드': ['우리카드', 'woori'],
'하나카드': ['하나카드', 'hana card', 'hana'],
'농협카드': ['농협', 'nh', '농협카드'],
'BC카드': ['bc카드', 'bc card'],
'삼성카드': ['삼성카드', 'samsung card'],
'롯데카드': ['롯데카드', 'lotte card'],
'현대카드': ['현대카드', 'hyundai card'],
'씨티카드': ['씨티카드', 'citi card', 'citibank'],
'카카오뱅크': ['카카오뱅크', 'kakaobank'],
'토스뱅크': ['토스뱅크', 'toss bank'],
'Visa': ['visa'],
'Mastercard': ['mastercard', 'master card'],
'American Express': ['amex', 'american express'],
};
for (final entry in issuerKeywords.entries) {
final match = entry.value.any((keyword) => normalized.contains(keyword));
if (match) {
return entry.key;
}
}
return null;
}
String? _detectCardLast4(String message) {
final patterns = [
RegExp(r'\*{3,}\s*(\d{4})'),
RegExp(r'끝번호\s*(\d{4})'),
RegExp(r'마지막\s*(\d{4})'),
RegExp(r'\((\d{4})\)'),
RegExp(r'ending(?: in)?\s*(\d{4})', caseSensitive: false),
];
for (final pattern in patterns) {
final match = pattern.firstMatch(message);
if (match != null && match.groupCount >= 1) {
final candidate = match.group(1);
if (candidate != null && candidate.length == 4) {
return candidate;
}
}
}
return null;
}
// 다음 결제일 계산 (현재 날짜 기준으로 조정)
DateTime _calculateNextBillingDate(
DateTime billingDate, String billingCycle) {

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/billing_cycle_selector.dart';
import '../common/form_fields/category_selector.dart';
import '../payment_card/payment_card_selector.dart';
import '../payment_card/payment_card_form_sheet.dart';
import '../../routes/app_routes.dart';
// Glass 제거: Material 3 Card 사용
// Material colors only
@@ -234,6 +237,35 @@ class AddSubscriptionForm extends StatelessWidget {
);
},
),
const SizedBox(height: 20),
Text(
AppLocalizations.of(context).paymentCard,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
PaymentCardSelector(
selectedCardId: controller.selectedPaymentCardId,
onChanged: (cardId) {
setState(() {
controller.selectedPaymentCardId = cardId;
});
},
onAddCard: () async {
final newCardId = await PaymentCardFormSheet.show(context);
if (newCardId != null) {
setState(() {
controller.selectedPaymentCardId = newCardId;
});
}
},
onManageCards: () {
Navigator.of(context)
.pushNamed(AppRoutes.paymentCardManagement);
},
),
],
),
),

View File

@@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:provider/provider.dart';
import '../../providers/subscription_provider.dart';
import '../../models/subscription_model.dart';
import '../../services/currency_util.dart';
// Glass 제거: Material 3 Card 사용
import '../themed_text.dart';
@@ -11,303 +11,283 @@ import '../../theme/color_scheme_ext.dart';
/// 이벤트 할인 현황을 보여주는 카드 위젯
class EventAnalysisCard extends StatelessWidget {
final AnimationController animationController;
final List<SubscriptionModel> subscriptions;
const EventAnalysisCard({
super.key,
required this.animationController,
required this.subscriptions,
});
@override
Widget build(BuildContext context) {
final activeEventSubscriptions =
subscriptions.where((sub) => sub.isCurrentlyInEvent).toList();
if (activeEventSubscriptions.isEmpty) {
return const SliverToBoxAdapter(child: SizedBox.shrink());
}
final totalSavings = activeEventSubscriptions.fold<double>(
0,
(sum, sub) => sum + sub.eventSavings,
);
return SliverToBoxAdapter(
child: Consumer<SubscriptionProvider>(
builder: (context, provider, child) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: provider.activeEventSubscriptions.isNotEmpty
? FadeTransition(
opacity: CurvedAnimation(
parent: animationController,
curve: const Interval(0.6, 1.0, curve: Curves.easeOut),
),
child: SlideTransition(
position: Tween<Offset>(
begin: const Offset(0, 0.2),
end: Offset.zero,
).animate(CurvedAnimation(
parent: animationController,
curve: const Interval(0.6, 1.0, curve: Curves.easeOut),
)),
child: Card(
elevation: 3,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(
color: Theme.of(context)
.colorScheme
.outline
.withValues(alpha: 0.5),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: FadeTransition(
opacity: CurvedAnimation(
parent: animationController,
curve: const Interval(0.6, 1.0, curve: Curves.easeOut),
),
child: SlideTransition(
position: Tween<Offset>(
begin: const Offset(0, 0.2),
end: Offset.zero,
).animate(
CurvedAnimation(
parent: animationController,
curve: const Interval(0.6, 1.0, curve: Curves.easeOut),
),
),
child: Card(
elevation: 3,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(
color: Theme.of(context)
.colorScheme
.outline
.withValues(alpha: 0.5),
),
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
ThemedText.headline(
text:
AppLocalizations.of(context).eventDiscountStatus,
style: const TextStyle(fontSize: 18),
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.error,
borderRadius: BorderRadius.circular(4),
),
child: Row(
children: [
Row(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [
ThemedText.headline(
text: AppLocalizations.of(context)
.eventDiscountStatus,
style: const TextStyle(
fontSize: 18,
),
),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color:
Theme.of(context).colorScheme.error,
borderRadius: BorderRadius.circular(4),
),
child: Row(
children: [
FaIcon(
FontAwesomeIcons.fire,
size: 12,
color: Theme.of(context)
.colorScheme
.onError,
),
const SizedBox(width: 4),
Text(
AppLocalizations.of(context)
.servicesInProgress(provider
.activeEventSubscriptions
.length),
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: Theme.of(context)
.colorScheme
.onError,
),
),
],
),
),
],
FaIcon(
FontAwesomeIcons.fire,
size: 12,
color: Theme.of(context).colorScheme.onError,
),
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Theme.of(context)
.colorScheme
.error
.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: Theme.of(context)
.colorScheme
.error
.withValues(alpha: 0.3),
),
),
child: Row(
children: [
Icon(
Icons.savings,
color:
Theme.of(context).colorScheme.error,
size: 32,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
ThemedText(
AppLocalizations.of(context)
.monthlySavingAmount,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 4),
ThemedText(
CurrencyUtil.formatTotalAmount(
provider.calculateTotalSavings(),
),
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Theme.of(context)
.colorScheme
.error,
),
),
],
),
),
],
),
),
const SizedBox(height: 16),
ThemedText(
AppLocalizations.of(context).eventsInProgress,
style: const TextStyle(
fontSize: 14,
const SizedBox(width: 4),
Text(
AppLocalizations.of(context).servicesInProgress(
activeEventSubscriptions.length),
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.onError,
),
),
const SizedBox(height: 12),
...provider.activeEventSubscriptions.map((sub) {
final savings = sub.originalPrice -
(sub.eventPrice ?? sub.originalPrice);
final discountRate =
((savings / sub.originalPrice) * 100)
.round();
return Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Theme.of(context)
.colorScheme
.onSurface
.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: Theme.of(context)
.colorScheme
.onSurfaceVariant
.withValues(alpha: 0.2),
),
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
ThemedText(
sub.serviceName,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Row(
children: [
FutureBuilder<String>(
future:
CurrencyUtil.formatAmount(
sub.originalPrice,
sub.currency),
builder: (context, snapshot) {
if (snapshot.hasData) {
return ThemedText(
snapshot.data!,
style: TextStyle(
fontSize: 12,
decoration:
TextDecoration
.lineThrough,
color: Theme.of(
context)
.colorScheme
.onSurfaceVariant,
),
);
}
return const SizedBox();
},
),
const SizedBox(width: 8),
Icon(
Icons.arrow_forward,
size: 12,
color: Theme.of(context)
.colorScheme
.onSurfaceVariant,
),
const SizedBox(width: 8),
FutureBuilder<String>(
future:
CurrencyUtil.formatAmount(
sub.eventPrice ??
sub.originalPrice,
sub.currency),
builder: (context, snapshot) {
if (snapshot.hasData) {
return ThemedText(
snapshot.data!,
style: TextStyle(
fontSize: 12,
fontWeight:
FontWeight.bold,
color:
Theme.of(context)
.colorScheme
.success,
),
);
}
return const SizedBox();
},
),
],
),
],
),
),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: Theme.of(context)
.colorScheme
.error
.withValues(alpha: 0.2),
borderRadius:
BorderRadius.circular(4),
),
child: Text(
_formatDiscountPercent(
context, discountRate),
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: Theme.of(context)
.colorScheme
.error,
),
),
),
],
),
);
}),
],
),
),
],
),
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Theme.of(context)
.colorScheme
.error
.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Theme.of(context)
.colorScheme
.error
.withValues(alpha: 0.2),
),
),
child: Row(
children: [
Icon(
Icons.savings,
color: Theme.of(context).colorScheme.error,
size: 32,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ThemedText(
AppLocalizations.of(context)
.monthlySavingAmount,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 4),
ThemedText(
CurrencyUtil.formatTotalAmount(totalSavings),
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.error,
),
),
],
),
),
],
),
),
)
: const SizedBox.shrink(),
);
},
const SizedBox(height: 16),
ThemedText(
AppLocalizations.of(context).eventsInProgress,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 12),
...activeEventSubscriptions.map((sub) {
final savings = sub.originalPrice -
(sub.eventPrice ?? sub.originalPrice);
final discountRate =
((savings / sub.originalPrice) * 100).round();
return Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Theme.of(context)
.colorScheme
.onSurface
.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: Theme.of(context)
.colorScheme
.onSurfaceVariant
.withValues(alpha: 0.2),
),
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ThemedText(
sub.serviceName,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Row(
children: [
FutureBuilder<String>(
future: CurrencyUtil.formatAmount(
sub.originalPrice, sub.currency),
builder: (context, snapshot) {
if (snapshot.hasData) {
return ThemedText(
snapshot.data!,
style: TextStyle(
fontSize: 12,
decoration:
TextDecoration.lineThrough,
color: Theme.of(context)
.colorScheme
.onSurfaceVariant,
),
);
}
return const SizedBox();
},
),
const SizedBox(width: 8),
Icon(
Icons.arrow_forward,
size: 12,
color: Theme.of(context)
.colorScheme
.onSurfaceVariant,
),
const SizedBox(width: 8),
FutureBuilder<String>(
future: CurrencyUtil.formatAmount(
sub.eventPrice ?? sub.originalPrice,
sub.currency,
),
builder: (context, snapshot) {
if (snapshot.hasData) {
return ThemedText(
snapshot.data!,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: Theme.of(context)
.colorScheme
.success,
),
);
}
return const SizedBox();
},
),
],
),
],
),
),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: Theme.of(context)
.colorScheme
.error
.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(4),
),
child: Text(
_formatDiscountPercent(context, discountRate),
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.error,
),
),
),
],
),
);
}),
],
),
),
),
),
),
),
);
}

View File

@@ -77,6 +77,12 @@ class AppNavigator {
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 {
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/billing_cycle_selector.dart';
import '../common/form_fields/category_selector.dart';
import '../payment_card/payment_card_selector.dart';
import '../payment_card/payment_card_form_sheet.dart';
import '../../routes/app_routes.dart';
import '../../l10n/app_localizations.dart';
/// 상세 화면 폼 섹션
@@ -184,6 +187,33 @@ class DetailFormSection extends StatelessWidget {
);
},
),
const SizedBox(height: 20),
Text(
AppLocalizations.of(context).paymentCard,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Theme.of(context).colorScheme.onSurface,
),
),
const SizedBox(height: 8),
PaymentCardSelector(
selectedCardId: controller.selectedPaymentCardId,
onChanged: (cardId) {
controller.selectedPaymentCardId = cardId;
},
onAddCard: () async {
final newCardId =
await PaymentCardFormSheet.show(context);
if (newCardId != null) {
controller.selectedPaymentCardId = newCardId;
}
},
onManageCards: () {
Navigator.of(context)
.pushNamed(AppRoutes.paymentCardManagement);
},
),
],
),
),

View File

@@ -3,7 +3,12 @@ import 'package:provider/provider.dart';
import '../../models/subscription_model.dart';
import '../../controllers/detail_screen_controller.dart';
import '../../providers/locale_provider.dart';
import '../../providers/payment_card_provider.dart';
import '../../services/currency_util.dart';
import '../../utils/payment_card_utils.dart';
import '../../models/payment_card_model.dart';
import '../payment_card/payment_card_form_sheet.dart';
import '../../routes/app_routes.dart';
import '../website_icon.dart';
import '../../l10n/app_localizations.dart';
@@ -30,6 +35,10 @@ class DetailHeaderSection extends StatelessWidget {
return Consumer<DetailScreenController>(
builder: (context, controller, child) {
final baseColor = controller.getCardColor();
final paymentCardProvider = context.watch<PaymentCardProvider>();
final paymentCard = paymentCardProvider.getCardById(
controller.selectedPaymentCardId ?? subscription.paymentCardId,
);
return Container(
height: 320,
@@ -172,6 +181,11 @@ class DetailHeaderSection extends StatelessWidget {
.withValues(alpha: 0.8),
),
),
const SizedBox(height: 12),
_buildPaymentCardChip(
context,
paymentCard,
),
],
),
),
@@ -268,6 +282,104 @@ class DetailHeaderSection extends StatelessWidget {
return cycle;
}
}
Widget _buildPaymentCardChip(
BuildContext context,
PaymentCardModel? card,
) {
final loc = AppLocalizations.of(context);
if (card == null) {
return GestureDetector(
onTap: () {
Navigator.of(context).pushNamed(AppRoutes.paymentCardManagement);
},
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(24),
border: Border.all(
color: Colors.white.withValues(alpha: 0.2),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.credit_card_off_rounded,
color: Colors.white,
size: 16,
),
const SizedBox(width: 8),
Text(
loc.paymentCardUnassigned,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
const SizedBox(width: 8),
Icon(
Icons.arrow_forward_ios_rounded,
color: Colors.white.withValues(alpha: 0.7),
size: 14,
),
],
),
),
);
}
final color = PaymentCardUtils.colorFromHex(card.colorHex);
final icon = PaymentCardUtils.iconForName(card.iconName);
return GestureDetector(
onTap: () async {
await PaymentCardFormSheet.show(context, card: card);
},
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 9),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(28),
border: Border.all(
color: color.withValues(alpha: 0.5),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
CircleAvatar(
radius: 14,
backgroundColor: Colors.white,
child: Icon(
icon,
size: 16,
color: color,
),
),
const SizedBox(width: 10),
Text(
'${card.issuerName} · ****${card.last4}',
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w700,
color: Colors.white,
),
),
const SizedBox(width: 8),
Icon(
Icons.edit_rounded,
size: 16,
color: Colors.white.withValues(alpha: 0.8),
),
],
),
),
);
}
}
/// 정보 표시 컬럼

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:provider/provider.dart';
import '../providers/subscription_provider.dart';
import '../providers/category_provider.dart';
import '../utils/subscription_category_helper.dart';
import '../widgets/native_ad_widget.dart';
import '../widgets/main_summary_card.dart';
import '../widgets/subscription_list_widget.dart';
import '../widgets/empty_state_widget.dart';
// import '../theme/app_colors.dart';
import '../l10n/app_localizations.dart';
import 'package:shared_preferences/shared_preferences.dart';
class HomeContent extends StatelessWidget {
import '../l10n/app_localizations.dart';
import '../providers/category_provider.dart';
import '../providers/payment_card_provider.dart';
import '../providers/subscription_provider.dart';
import '../utils/subscription_grouping_helper.dart';
import '../widgets/empty_state_widget.dart';
import '../widgets/main_summary_card.dart';
import '../widgets/native_ad_widget.dart';
import '../widgets/subscription_list_widget.dart';
class HomeContent extends StatefulWidget {
final AnimationController fadeController;
final AnimationController rotateController;
final AnimationController slideController;
@@ -31,10 +33,53 @@ class HomeContent extends StatelessWidget {
});
@override
Widget build(BuildContext context) {
final provider = context.watch<SubscriptionProvider>();
State<HomeContent> createState() => _HomeContentState();
}
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(
child: CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(
@@ -44,32 +89,30 @@ class HomeContent extends StatelessWidget {
);
}
if (provider.subscriptions.isEmpty) {
if (subscriptionProvider.subscriptions.isEmpty) {
return EmptyStateWidget(
fadeController: fadeController,
rotateController: rotateController,
slideController: slideController,
onAddPressed: onAddPressed,
fadeController: widget.fadeController,
rotateController: widget.rotateController,
slideController: widget.slideController,
onAddPressed: widget.onAddPressed,
);
}
// 카테고리별 구독 구분
final categoryProvider =
Provider.of<CategoryProvider>(context, listen: false);
final categorizedSubscriptions =
SubscriptionCategoryHelper.categorizeSubscriptions(
provider.subscriptions,
categoryProvider,
context,
final groupedSubscriptions = SubscriptionGroupingHelper.buildGroups(
context: context,
subscriptions: subscriptionProvider.subscriptions,
mode: _groupingMode,
categoryProvider: categoryProvider,
paymentCardProvider: paymentCardProvider,
);
return RefreshIndicator(
onRefresh: () async {
await provider.refreshSubscriptions();
await subscriptionProvider.refreshSubscriptions();
},
color: Theme.of(context).colorScheme.primary,
child: CustomScrollView(
controller: scrollController,
controller: widget.scrollController,
physics: const BouncingScrollPhysics(),
slivers: [
SliverToBoxAdapter(
@@ -86,13 +129,13 @@ class HomeContent extends StatelessWidget {
begin: const Offset(0, 0.2),
end: Offset.zero,
).animate(CurvedAnimation(
parent: slideController, curve: Curves.easeOutCubic)),
parent: widget.slideController, curve: Curves.easeOutCubic)),
child: MainScreenSummaryCard(
provider: provider,
fadeController: fadeController,
pulseController: pulseController,
waveController: waveController,
slideController: slideController,
provider: subscriptionProvider,
fadeController: widget.fadeController,
pulseController: widget.pulseController,
waveController: widget.waveController,
slideController: widget.slideController,
),
),
),
@@ -107,7 +150,8 @@ class HomeContent extends StatelessWidget {
begin: const Offset(-0.2, 0),
end: Offset.zero,
).animate(CurvedAnimation(
parent: slideController, curve: Curves.easeOutCubic)),
parent: widget.slideController,
curve: Curves.easeOutCubic)),
child: Text(
AppLocalizations.of(context).mySubscriptions,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
@@ -120,12 +164,13 @@ class HomeContent extends StatelessWidget {
begin: const Offset(0.2, 0),
end: Offset.zero,
).animate(CurvedAnimation(
parent: slideController, curve: Curves.easeOutCubic)),
parent: widget.slideController,
curve: Curves.easeOutCubic)),
child: Row(
children: [
Text(
AppLocalizations.of(context)
.subscriptionCount(provider.subscriptions.length),
AppLocalizations.of(context).subscriptionCount(
subscriptionProvider.subscriptions.length),
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
@@ -145,9 +190,33 @@ class HomeContent extends StatelessWidget {
),
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
child: Wrap(
spacing: 8,
children: [
ChoiceChip(
label: Text(AppLocalizations.of(context).category),
selected:
_groupingMode == SubscriptionGroupingMode.category,
onSelected: (_) =>
_updateGroupingMode(SubscriptionGroupingMode.category),
),
ChoiceChip(
label: Text(AppLocalizations.of(context).paymentCard),
selected:
_groupingMode == SubscriptionGroupingMode.paymentCard,
onSelected: (_) => _updateGroupingMode(
SubscriptionGroupingMode.paymentCard),
),
],
),
),
),
SubscriptionListWidget(
categorizedSubscriptions: categorizedSubscriptions,
fadeController: fadeController,
groups: groupedSubscriptions,
fadeController: widget.fadeController,
),
SliverToBoxAdapter(
child: SizedBox(

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:provider/provider.dart';
import '../../models/subscription.dart';
import '../../models/payment_card_suggestion.dart';
import '../../providers/category_provider.dart';
import '../../providers/locale_provider.dart';
import '../../widgets/themed_text.dart';
@@ -10,6 +11,7 @@ import '../../widgets/common/form_fields/base_text_field.dart';
import '../../widgets/common/form_fields/category_selector.dart';
import '../../widgets/common/snackbar/app_snackbar.dart';
import '../../widgets/native_ad_widget.dart';
import '../../widgets/payment_card/payment_card_selector.dart';
import '../../services/currency_util.dart';
import '../../utils/sms_scan/date_formatter.dart';
import '../../utils/sms_scan/category_icon_mapper.dart';
@@ -20,8 +22,16 @@ class SubscriptionCardWidget extends StatefulWidget {
final TextEditingController websiteUrlController;
final String? selectedCategoryId;
final Function(String?) onCategoryChanged;
final String? selectedPaymentCardId;
final Function(String?) onPaymentCardChanged;
final Future<void> Function()? onAddCard;
final VoidCallback? onManageCards;
final VoidCallback onAdd;
final VoidCallback onSkip;
final PaymentCardSuggestion? detectedCardSuggestion;
final bool showDetectedCardShortcut;
final Future<void> Function(PaymentCardSuggestion suggestion)?
onAddDetectedCard;
const SubscriptionCardWidget({
super.key,
@@ -29,8 +39,15 @@ class SubscriptionCardWidget extends StatefulWidget {
required this.websiteUrlController,
this.selectedCategoryId,
required this.onCategoryChanged,
required this.selectedPaymentCardId,
required this.onPaymentCardChanged,
this.onAddCard,
this.onManageCards,
required this.onAdd,
required this.onSkip,
this.detectedCardSuggestion,
this.showDetectedCardShortcut = false,
this.onAddDetectedCard,
});
@override
@@ -246,6 +263,39 @@ class _SubscriptionCardWidgetState extends State<SubscriptionCardWidget> {
),
const SizedBox(height: 24),
// 결제수단 선택
ThemedText(
AppLocalizations.of(context).paymentCard,
fontWeight: FontWeight.w500,
opacity: 0.7,
),
const SizedBox(height: 8),
PaymentCardSelector(
selectedCardId: widget.selectedPaymentCardId,
onChanged: widget.onPaymentCardChanged,
onAddCard: widget.onAddCard,
onManageCards: widget.onManageCards,
),
if (widget.showDetectedCardShortcut &&
widget.detectedCardSuggestion != null) ...[
const SizedBox(height: 12),
_DetectedCardSuggestionBanner(
suggestion: widget.detectedCardSuggestion!,
onAdd: widget.onAddDetectedCard,
),
],
if (widget.selectedPaymentCardId == null) ...[
const SizedBox(height: 8),
Text(
AppLocalizations.of(context).paymentCardUnassignedWarning,
style: TextStyle(
fontSize: 12,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
const SizedBox(height: 24),
// 웹사이트 URL 입력 필드
BaseTextField(
controller: widget.websiteUrlController,
@@ -297,3 +347,84 @@ class _SubscriptionCardWidgetState extends State<SubscriptionCardWidget> {
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 '../models/subscription_model.dart';
import '../providers/category_provider.dart';
import '../providers/payment_card_provider.dart';
import '../providers/locale_provider.dart';
import '../services/subscription_url_matcher.dart';
import '../services/currency_util.dart';
import '../utils/billing_date_util.dart';
import '../utils/payment_card_utils.dart';
import 'website_icon.dart';
import 'app_navigator.dart';
// import '../theme/app_colors.dart';
@@ -299,6 +301,7 @@ class _SubscriptionCardState extends State<SubscriptionCard>
Widget build(BuildContext context) {
// LocaleProvider를 watch하여 언어 변경시 자동 업데이트
final localeProvider = context.watch<LocaleProvider>();
final paymentCardProvider = context.watch<PaymentCardProvider>();
// 언어가 변경되면 displayName 다시 로드
WidgetsBinding.instance.addPostFrameCallback((_) {
@@ -464,7 +467,10 @@ class _SubscriptionCardState extends State<SubscriptionCard>
],
),
const SizedBox(height: 6),
const SizedBox(height: 8),
_buildPaymentCardBadge(
context, paymentCardProvider),
const SizedBox(height: 8),
// 가격 정보
Row(
@@ -673,4 +679,57 @@ 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: CircleAvatar(
backgroundColor: Colors.white,
child: Icon(
icon,
size: 14,
color: color,
),
),
label: Text(
'${card.issuerName} · ****${card.last4}',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: color,
),
),
side: BorderSide(color: color.withValues(alpha: 0.3)),
backgroundColor: color.withValues(alpha: 0.12),
padding: const EdgeInsets.symmetric(horizontal: 8),
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
);
}
}

View File

@@ -0,0 +1,164 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import '../l10n/app_localizations.dart';
import '../utils/payment_card_utils.dart';
import '../utils/subscription_grouping_helper.dart';
class SubscriptionGroupHeader extends StatelessWidget {
final SubscriptionGroupData group;
final int subscriptionCount;
final double totalCostUSD;
final double totalCostKRW;
final double totalCostJPY;
final double totalCostCNY;
const SubscriptionGroupHeader({
super.key,
required this.group,
required this.subscriptionCount,
required this.totalCostUSD,
required this.totalCostKRW,
required this.totalCostJPY,
required this.totalCostCNY,
});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Row(
children: [
if (group.mode == SubscriptionGroupingMode.paymentCard &&
group.paymentCard != null)
_PaymentCardAvatar(colorHex: group.paymentCard!.colorHex)
else if (group.mode == SubscriptionGroupingMode.paymentCard)
const _PaymentCardAvatar(),
if (group.mode == SubscriptionGroupingMode.paymentCard)
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
group.title,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: scheme.onSurface,
),
),
if (group.subtitle != null)
Text(
group.subtitle!,
style: TextStyle(
fontSize: 13,
color: scheme.onSurfaceVariant,
),
),
],
),
),
],
),
),
Text(
_buildCostDisplay(context),
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: scheme.onSurfaceVariant,
),
),
],
),
const SizedBox(height: 8),
Divider(
height: 1,
thickness: 1,
color: scheme.outline.withValues(alpha: 0.3),
),
],
),
);
}
String _buildCostDisplay(BuildContext context) {
final parts = <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 '../models/subscription_model.dart';
import '../widgets/category_header_widget.dart';
import '../widgets/subscription_group_header.dart';
import '../widgets/swipeable_subscription_card.dart';
import '../widgets/staggered_list_animation.dart';
import '../widgets/app_navigator.dart';
import 'package:provider/provider.dart';
import '../providers/subscription_provider.dart';
import '../providers/locale_provider.dart';
import '../providers/category_provider.dart';
import '../services/subscription_url_matcher.dart';
import './dialogs/delete_confirmation_dialog.dart';
import './common/snackbar/app_snackbar.dart';
import '../l10n/app_localizations.dart';
import '../utils/logger.dart';
import '../utils/subscription_grouping_helper.dart';
/// 카테고리별로 구독 목록을 표시하는 위젯
class SubscriptionListWidget extends StatelessWidget {
final Map<String, List<SubscriptionModel>> categorizedSubscriptions;
final List<SubscriptionGroupData> groups;
final AnimationController fadeController;
const SubscriptionListWidget({
super.key,
required this.categorizedSubscriptions,
required this.groups,
required this.fadeController,
});
@override
Widget build(BuildContext context) {
// 카테고리 키 목록 (정렬된)
final categories = categorizedSubscriptions.keys.toList();
final sections = groups;
return SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
final category = categories[index];
final subscriptions = categorizedSubscriptions[category]!;
final group = sections[index];
final subscriptions = group.subscriptions;
return Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 카테고리 헤더
Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 8),
child: Consumer<CategoryProvider>(
builder: (context, categoryProvider, child) {
return CategoryHeaderWidget(
categoryName: categoryProvider.getLocalizedCategoryName(
context, category),
subscriptionCount: subscriptions.length,
totalCostUSD:
_calculateTotalByCurrency(subscriptions, 'USD'),
totalCostKRW:
_calculateTotalByCurrency(subscriptions, 'KRW'),
totalCostJPY:
_calculateTotalByCurrency(subscriptions, 'JPY'),
totalCostCNY:
_calculateTotalByCurrency(subscriptions, 'CNY'),
);
},
),
SubscriptionGroupHeader(
group: group,
subscriptionCount: subscriptions.length,
totalCostUSD: _calculateTotalByCurrency(subscriptions, 'USD'),
totalCostKRW: _calculateTotalByCurrency(subscriptions, 'KRW'),
totalCostJPY: _calculateTotalByCurrency(subscriptions, 'JPY'),
totalCostCNY: _calculateTotalByCurrency(subscriptions, 'CNY'),
),
// 카테고리별 구독 목록
FadeTransition(
@@ -169,7 +155,7 @@ class SubscriptionListWidget extends StatelessWidget {
),
);
},
childCount: categories.length,
childCount: sections.length,
),
);
}