feat: add payment card grouping and analysis
This commit is contained in:
@@ -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