feat: add payment card grouping and analysis
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
// 이벤트 정보 업데이트
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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),
|
||||
|
||||
33
lib/models/payment_card_model.dart
Normal file
33
lib/models/payment_card_model.dart
Normal 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,
|
||||
});
|
||||
}
|
||||
56
lib/models/payment_card_model.g.dart
Normal file
56
lib/models/payment_card_model.g.dart
Normal 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;
|
||||
}
|
||||
14
lib/models/payment_card_suggestion.dart
Normal file
14
lib/models/payment_card_suggestion.dart
Normal 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;
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
// 주기적 결제 여부 확인
|
||||
|
||||
@@ -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
|
||||
|
||||
124
lib/providers/payment_card_provider.dart
Normal file
124
lib/providers/payment_card_provider.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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를 위한 충분한 하단 여백
|
||||
|
||||
@@ -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,
|
||||
|
||||
144
lib/screens/payment_card_management_screen.dart
Normal file
144
lib/screens/payment_card_management_screen.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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(
|
||||
|
||||
14
lib/services/sms_scan/sms_scan_result.dart
Normal file
14
lib/services/sms_scan/sms_scan_result.dart
Normal 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,
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
41
lib/utils/payment_card_utils.dart
Normal file
41
lib/utils/payment_card_utils.dart
Normal 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));
|
||||
}
|
||||
}
|
||||
155
lib/utils/subscription_grouping_helper.dart
Normal file
155
lib/utils/subscription_grouping_helper.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(' · ');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 정보 표시 컬럼
|
||||
|
||||
196
lib/widgets/detail/detail_payment_info_section.dart
Normal file
196
lib/widgets/detail/detail_payment_info_section.dart
Normal 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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
290
lib/widgets/payment_card/payment_card_form_sheet.dart
Normal file
290
lib/widgets/payment_card/payment_card_form_sheet.dart
Normal 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;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
142
lib/widgets/payment_card/payment_card_selector.dart
Normal file
142
lib/widgets/payment_card/payment_card_selector.dart
Normal 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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
164
lib/widgets/subscription_group_header.dart
Normal file
164
lib/widgets/subscription_group_header.dart
Normal 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user