feat: add payment card grouping and analysis

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

View File

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

View File

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

View File

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

View File

@@ -1,126 +0,0 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import '../l10n/app_localizations.dart';
/// 카테고리별 구독 그룹의 헤더 위젯
///
/// 카테고리 이름, 구독 개수, 총 비용을 표시합니다.
/// 통화별로 구분하여 표시하며, 혼재된 경우 각각 표시합니다.
class CategoryHeaderWidget extends StatelessWidget {
final String categoryName;
final int subscriptionCount;
final double totalCostUSD;
final double totalCostKRW;
final double totalCostJPY;
final double totalCostCNY;
const CategoryHeaderWidget({
super.key,
required this.categoryName,
required this.subscriptionCount,
required this.totalCostUSD,
required this.totalCostKRW,
required this.totalCostJPY,
required this.totalCostCNY,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
categoryName,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: Theme.of(context).colorScheme.onSurface,
),
),
Text(
_buildCostDisplay(context),
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
const SizedBox(height: 8),
Divider(
height: 1,
thickness: 1,
color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3),
),
],
),
);
}
/// 통화별 합계를 표시하는 문자열을 생성합니다.
String _buildCostDisplay(BuildContext context) {
final parts = <String>[];
// 개수는 항상 표시
parts
.add(AppLocalizations.of(context).subscriptionCount(subscriptionCount));
// 통화 부분을 별도로 처리
final currencyParts = <String>[];
// 달러가 있는 경우
if (totalCostUSD > 0) {
final formatter = NumberFormat.currency(
locale: 'en_US',
symbol: '\$',
decimalDigits: 2,
);
currencyParts.add(formatter.format(totalCostUSD));
}
// 원화가 있는 경우
if (totalCostKRW > 0) {
final formatter = NumberFormat.currency(
locale: 'ko_KR',
symbol: '',
decimalDigits: 0,
);
currencyParts.add(formatter.format(totalCostKRW));
}
// 엔화가 있는 경우
if (totalCostJPY > 0) {
final formatter = NumberFormat.currency(
locale: 'ja_JP',
symbol: '¥',
decimalDigits: 0,
);
currencyParts.add(formatter.format(totalCostJPY));
}
// 위안화가 있는 경우
if (totalCostCNY > 0) {
final formatter = NumberFormat.currency(
locale: 'zh_CN',
symbol: '¥',
decimalDigits: 2,
);
currencyParts.add(formatter.format(totalCostCNY));
}
// 통화가 하나 이상 있는 경우
if (currencyParts.isNotEmpty) {
// 통화가 여러 개인 경우 + 로 연결, 하나인 경우 그대로
final currencyDisplay = currencyParts.join(' + ');
parts.add(currencyDisplay);
}
return parts.join(' · ');
}
}

View File

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

View File

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

View File

@@ -0,0 +1,196 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../controllers/detail_screen_controller.dart';
import '../../models/payment_card_model.dart';
import '../../providers/payment_card_provider.dart';
import '../../routes/app_routes.dart';
import '../../utils/payment_card_utils.dart';
import '../payment_card/payment_card_form_sheet.dart';
import '../../l10n/app_localizations.dart';
/// 상세 화면 결제 정보 섹션
/// 현재 구독에 연결된 결제수단 정보를 요약하여 보여준다.
class DetailPaymentInfoSection extends StatelessWidget {
final DetailScreenController controller;
final Animation<double> fadeAnimation;
final Animation<Offset> slideAnimation;
const DetailPaymentInfoSection({
super.key,
required this.controller,
required this.fadeAnimation,
required this.slideAnimation,
});
@override
Widget build(BuildContext context) {
return Consumer2<DetailScreenController, PaymentCardProvider>(
builder: (context, detailController, paymentCardProvider, child) {
final baseColor = detailController.getCardColor();
final paymentCard = paymentCardProvider.getCardById(
detailController.selectedPaymentCardId ??
detailController.subscription.paymentCardId,
);
return FadeTransition(
opacity: fadeAnimation,
child: SlideTransition(
position: slideAnimation,
child: Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: Theme.of(context)
.colorScheme
.outline
.withValues(alpha: 0.3),
width: 1,
),
),
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: baseColor.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
),
child: Icon(
Icons.credit_card_rounded,
color: baseColor,
size: 22,
),
),
const SizedBox(width: 12),
Text(
AppLocalizations.of(context).paymentCard,
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: Theme.of(context).colorScheme.onSurface,
),
),
const Spacer(),
TextButton.icon(
onPressed: () {
Navigator.of(context)
.pushNamed(AppRoutes.paymentCardManagement);
},
icon: const Icon(Icons.settings_rounded, size: 18),
label: Text(
AppLocalizations.of(context).paymentCardManagement,
),
),
],
),
const SizedBox(height: 16),
_PaymentCardInfoTile(
card: paymentCard,
onTap: () async {
if (paymentCard != null) {
await PaymentCardFormSheet.show(
context,
card: paymentCard,
);
} else {
Navigator.of(context)
.pushNamed(AppRoutes.paymentCardManagement);
}
},
),
],
),
),
),
),
);
},
);
}
}
class _PaymentCardInfoTile extends StatelessWidget {
final PaymentCardModel? card;
final VoidCallback onTap;
const _PaymentCardInfoTile({
required this.card,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final loc = AppLocalizations.of(context);
final hasCard = card != null;
final chipColor = hasCard
? PaymentCardUtils.colorFromHex(card!.colorHex)
: scheme.onSurfaceVariant;
final icon = hasCard
? PaymentCardUtils.iconForName(card!.iconName)
: Icons.credit_card_off_rounded;
return Material(
color: scheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(16),
child: InkWell(
borderRadius: BorderRadius.circular(16),
onTap: onTap,
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
CircleAvatar(
radius: 20,
backgroundColor: chipColor.withValues(alpha: 0.15),
child: Icon(
icon,
color: chipColor,
size: 20,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
loc.paymentCard,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: scheme.onSurfaceVariant,
),
),
const SizedBox(height: 4),
Text(
hasCard
? '${card!.issuerName} · ****${card!.last4}'
: loc.paymentCardUnassigned,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: scheme.onSurface,
),
),
],
),
),
Icon(
hasCard ? Icons.edit_rounded : Icons.add_rounded,
color: scheme.onSurfaceVariant,
),
],
),
),
),
);
}
}

View File

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

View File

@@ -0,0 +1,290 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../../l10n/app_localizations.dart';
import '../../models/payment_card_model.dart';
import '../../providers/payment_card_provider.dart';
import '../../utils/payment_card_utils.dart';
class PaymentCardFormSheet extends StatefulWidget {
final PaymentCardModel? card;
final String? initialIssuerName;
final String? initialLast4;
final String? initialColorHex;
final String? initialIconName;
const PaymentCardFormSheet({
super.key,
this.card,
this.initialIssuerName,
this.initialLast4,
this.initialColorHex,
this.initialIconName,
});
static Future<String?> show(
BuildContext context, {
PaymentCardModel? card,
String? initialIssuerName,
String? initialLast4,
String? initialColorHex,
String? initialIconName,
}) async {
return showModalBottomSheet<String>(
context: context,
isScrollControlled: true,
useSafeArea: true,
builder: (_) => PaymentCardFormSheet(
card: card,
initialIssuerName: initialIssuerName,
initialLast4: initialLast4,
initialColorHex: initialColorHex,
initialIconName: initialIconName,
),
);
}
@override
State<PaymentCardFormSheet> createState() => _PaymentCardFormSheetState();
}
class _PaymentCardFormSheetState extends State<PaymentCardFormSheet> {
final _formKey = GlobalKey<FormState>();
late TextEditingController _issuerController;
late TextEditingController _last4Controller;
late String _selectedColor;
late String _selectedIcon;
late bool _isDefault;
bool _isSaving = false;
@override
void initState() {
super.initState();
_issuerController = TextEditingController(
text: widget.card?.issuerName ?? widget.initialIssuerName ?? '',
);
_last4Controller = TextEditingController(
text: widget.card?.last4 ?? widget.initialLast4 ?? '',
);
_selectedColor = widget.card?.colorHex ??
widget.initialColorHex ??
PaymentCardUtils.colorPalette.first;
_selectedIcon = widget.card?.iconName ??
widget.initialIconName ??
PaymentCardUtils.iconMap.keys.first;
_isDefault = widget.card?.isDefault ?? false;
}
@override
void dispose() {
_issuerController.dispose();
_last4Controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final loc = AppLocalizations.of(context);
final isEditing = widget.card != null;
return Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
),
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 32),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
isEditing ? loc.editPaymentCard : loc.addPaymentCard,
style: Theme.of(context).textTheme.titleLarge,
),
const Spacer(),
IconButton(
onPressed: () => Navigator.of(context).pop(),
icon: const Icon(Icons.close),
),
],
),
const SizedBox(height: 16),
Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextFormField(
controller: _issuerController,
decoration: InputDecoration(
labelText: loc.paymentCardIssuer,
border: const OutlineInputBorder(),
),
textInputAction: TextInputAction.next,
validator: (value) {
if (value == null || value.trim().isEmpty) {
return loc.requiredFieldsError;
}
return null;
},
),
const SizedBox(height: 16),
TextFormField(
controller: _last4Controller,
decoration: InputDecoration(
labelText: loc.paymentCardLast4,
border: const OutlineInputBorder(),
counterText: '',
),
keyboardType: TextInputType.number,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(4),
],
validator: (value) {
if (value == null || value.length != 4) {
return loc.paymentCardLast4;
}
return null;
},
),
const SizedBox(height: 16),
Text(
loc.paymentCardColor,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: PaymentCardUtils.colorPalette.map((hex) {
final color = PaymentCardUtils.colorFromHex(hex);
final selected = _selectedColor == hex;
return GestureDetector(
onTap: () {
setState(() {
_selectedColor = hex;
});
},
child: Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
border: Border.all(
color: selected
? Theme.of(context).colorScheme.onSurface
: Colors.transparent,
width: 2,
),
),
),
);
}).toList(),
),
const SizedBox(height: 16),
Text(
loc.paymentCardIcon,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: PaymentCardUtils.iconMap.entries.map((entry) {
final selected = _selectedIcon == entry.key;
return ChoiceChip(
label: Icon(entry.value,
color: selected
? Theme.of(context).colorScheme.onPrimary
: Theme.of(context).colorScheme.onSurface),
selected: selected,
onSelected: (_) {
setState(() {
_selectedIcon = entry.key;
});
},
selectedColor: Theme.of(context).colorScheme.primary,
backgroundColor: Theme.of(context)
.colorScheme
.surfaceContainerHighest,
);
}).toList(),
),
const SizedBox(height: 16),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: Text(loc.setAsDefaultCard),
value: _isDefault,
onChanged: (value) {
setState(() {
_isDefault = value;
});
},
),
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: _isSaving ? null : _handleSubmit,
child: _isSaving
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Text(loc.save),
),
),
],
),
),
],
),
),
);
}
Future<void> _handleSubmit() async {
if (!_formKey.currentState!.validate()) return;
setState(() {
_isSaving = true;
});
try {
final provider = context.read<PaymentCardProvider>();
String cardId;
if (widget.card == null) {
final card = await provider.addCard(
issuerName: _issuerController.text.trim(),
last4: _last4Controller.text.trim(),
colorHex: _selectedColor,
iconName: _selectedIcon,
isDefault: _isDefault,
);
cardId = card.id;
} else {
widget.card!
..issuerName = _issuerController.text.trim()
..last4 = _last4Controller.text.trim()
..colorHex = _selectedColor
..iconName = _selectedIcon
..isDefault = _isDefault;
await provider.updateCard(widget.card!);
cardId = widget.card!.id;
}
if (mounted) {
Navigator.of(context).pop(cardId);
}
} finally {
if (mounted) {
setState(() {
_isSaving = false;
});
}
}
}
}

View File

@@ -0,0 +1,142 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../l10n/app_localizations.dart';
import '../../models/payment_card_model.dart';
import '../../providers/payment_card_provider.dart';
import '../../utils/payment_card_utils.dart';
class PaymentCardSelector extends StatelessWidget {
final String? selectedCardId;
final ValueChanged<String?> onChanged;
final Future<void> Function()? onAddCard;
final VoidCallback? onManageCards;
const PaymentCardSelector({
super.key,
required this.selectedCardId,
required this.onChanged,
this.onAddCard,
this.onManageCards,
});
@override
Widget build(BuildContext context) {
return Consumer<PaymentCardProvider>(
builder: (context, provider, child) {
final loc = AppLocalizations.of(context);
final cards = provider.cards;
final unassignedSelected = selectedCardId == null;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Wrap(
spacing: 8,
runSpacing: 8,
children: [
Semantics(
label: loc.paymentCardUnassigned,
selected: unassignedSelected,
button: true,
child: ChoiceChip(
label: Text(loc.paymentCardUnassigned),
selected: unassignedSelected,
onSelected: (_) => onChanged(null),
avatar: const Icon(Icons.credit_card_off_rounded, size: 18),
),
),
...cards.map((card) => _PaymentCardChip(
card: card,
isSelected: selectedCardId == card.id,
onSelected: () => onChanged(card.id),
)),
],
),
const SizedBox(height: 8),
Row(
children: [
TextButton.icon(
onPressed: cards.isEmpty && onAddCard == null
? null
: () async {
if (onAddCard != null) {
await onAddCard!();
}
},
icon: const Icon(Icons.add),
label: Text(loc.addNewCard),
),
const SizedBox(width: 8),
TextButton(
onPressed: onManageCards,
child: Text(loc.managePaymentCards),
),
],
),
if (cards.isEmpty)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
loc.noPaymentCards,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant,
fontSize: 13,
),
),
),
],
);
},
);
}
}
class _PaymentCardChip extends StatelessWidget {
final PaymentCardModel card;
final bool isSelected;
final VoidCallback onSelected;
const _PaymentCardChip({
required this.card,
required this.isSelected,
required this.onSelected,
});
@override
Widget build(BuildContext context) {
final color = PaymentCardUtils.colorFromHex(card.colorHex);
final icon = PaymentCardUtils.iconForName(card.iconName);
final cs = Theme.of(context).colorScheme;
final labelText = '${card.issuerName} · ****${card.last4}';
return Semantics(
label: labelText,
selected: isSelected,
button: true,
child: ChoiceChip(
avatar: CircleAvatar(
backgroundColor:
isSelected ? cs.onPrimary : color.withValues(alpha: 0.15),
child: Icon(
icon,
color: isSelected ? color : cs.onSurface,
size: 16,
),
),
label: Text(labelText),
selected: isSelected,
onSelected: (_) => onSelected(),
selectedColor: color,
labelStyle: TextStyle(
color: isSelected ? cs.onPrimary : cs.onSurface,
fontWeight: FontWeight.w600,
),
backgroundColor: cs.surface,
side: BorderSide(
color: isSelected
? Colors.transparent
: cs.outline.withValues(alpha: 0.5),
),
),
);
}
}

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../models/subscription.dart';
import '../../models/payment_card_suggestion.dart';
import '../../providers/category_provider.dart';
import '../../providers/locale_provider.dart';
import '../../widgets/themed_text.dart';
@@ -10,6 +11,7 @@ import '../../widgets/common/form_fields/base_text_field.dart';
import '../../widgets/common/form_fields/category_selector.dart';
import '../../widgets/common/snackbar/app_snackbar.dart';
import '../../widgets/native_ad_widget.dart';
import '../../widgets/payment_card/payment_card_selector.dart';
import '../../services/currency_util.dart';
import '../../utils/sms_scan/date_formatter.dart';
import '../../utils/sms_scan/category_icon_mapper.dart';
@@ -20,8 +22,16 @@ class SubscriptionCardWidget extends StatefulWidget {
final TextEditingController websiteUrlController;
final String? selectedCategoryId;
final Function(String?) onCategoryChanged;
final String? selectedPaymentCardId;
final Function(String?) onPaymentCardChanged;
final Future<void> Function()? onAddCard;
final VoidCallback? onManageCards;
final VoidCallback onAdd;
final VoidCallback onSkip;
final PaymentCardSuggestion? detectedCardSuggestion;
final bool showDetectedCardShortcut;
final Future<void> Function(PaymentCardSuggestion suggestion)?
onAddDetectedCard;
const SubscriptionCardWidget({
super.key,
@@ -29,8 +39,15 @@ class SubscriptionCardWidget extends StatefulWidget {
required this.websiteUrlController,
this.selectedCategoryId,
required this.onCategoryChanged,
required this.selectedPaymentCardId,
required this.onPaymentCardChanged,
this.onAddCard,
this.onManageCards,
required this.onAdd,
required this.onSkip,
this.detectedCardSuggestion,
this.showDetectedCardShortcut = false,
this.onAddDetectedCard,
});
@override
@@ -246,6 +263,39 @@ class _SubscriptionCardWidgetState extends State<SubscriptionCardWidget> {
),
const SizedBox(height: 24),
// 결제수단 선택
ThemedText(
AppLocalizations.of(context).paymentCard,
fontWeight: FontWeight.w500,
opacity: 0.7,
),
const SizedBox(height: 8),
PaymentCardSelector(
selectedCardId: widget.selectedPaymentCardId,
onChanged: widget.onPaymentCardChanged,
onAddCard: widget.onAddCard,
onManageCards: widget.onManageCards,
),
if (widget.showDetectedCardShortcut &&
widget.detectedCardSuggestion != null) ...[
const SizedBox(height: 12),
_DetectedCardSuggestionBanner(
suggestion: widget.detectedCardSuggestion!,
onAdd: widget.onAddDetectedCard,
),
],
if (widget.selectedPaymentCardId == null) ...[
const SizedBox(height: 8),
Text(
AppLocalizations.of(context).paymentCardUnassignedWarning,
style: TextStyle(
fontSize: 12,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
const SizedBox(height: 24),
// 웹사이트 URL 입력 필드
BaseTextField(
controller: widget.websiteUrlController,
@@ -297,3 +347,84 @@ class _SubscriptionCardWidgetState extends State<SubscriptionCardWidget> {
return CategoryIconMapper.getCategoryColor(category);
}
}
class _DetectedCardSuggestionBanner extends StatelessWidget {
final PaymentCardSuggestion suggestion;
final Future<void> Function(PaymentCardSuggestion suggestion)? onAdd;
const _DetectedCardSuggestionBanner({
required this.suggestion,
this.onAdd,
});
@override
Widget build(BuildContext context) {
final loc = AppLocalizations.of(context);
final scheme = Theme.of(context).colorScheme;
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: scheme.secondaryContainer,
borderRadius: BorderRadius.circular(16),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: scheme.onSecondaryContainer.withValues(alpha: 0.1),
shape: BoxShape.circle,
),
child: Icon(
Icons.auto_fix_high_rounded,
color: scheme.onSecondaryContainer,
size: 20,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
loc.detectedPaymentCard,
style: TextStyle(
fontWeight: FontWeight.w600,
color: scheme.onSecondaryContainer,
),
),
const SizedBox(height: 4),
Text(
loc.detectedPaymentCardDescription(
suggestion.issuerName,
suggestion.last4 ?? '****',
),
style: TextStyle(
fontSize: 13,
color: scheme.onSecondaryContainer.withValues(alpha: 0.9),
),
),
],
),
),
const SizedBox(width: 12),
ElevatedButton.icon(
onPressed: onAdd == null
? null
: () async {
await onAdd!(suggestion);
},
style: ElevatedButton.styleFrom(
backgroundColor: scheme.onSecondaryContainer,
foregroundColor: scheme.secondaryContainer,
),
icon: const Icon(Icons.add_rounded, size: 16),
label: Text(loc.addDetectedPaymentCard),
),
],
),
);
}
}

View File

@@ -2,10 +2,12 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../models/subscription_model.dart';
import '../providers/category_provider.dart';
import '../providers/payment_card_provider.dart';
import '../providers/locale_provider.dart';
import '../services/subscription_url_matcher.dart';
import '../services/currency_util.dart';
import '../utils/billing_date_util.dart';
import '../utils/payment_card_utils.dart';
import 'website_icon.dart';
import 'app_navigator.dart';
// import '../theme/app_colors.dart';
@@ -299,6 +301,7 @@ class _SubscriptionCardState extends State<SubscriptionCard>
Widget build(BuildContext context) {
// LocaleProvider를 watch하여 언어 변경시 자동 업데이트
final localeProvider = context.watch<LocaleProvider>();
final paymentCardProvider = context.watch<PaymentCardProvider>();
// 언어가 변경되면 displayName 다시 로드
WidgetsBinding.instance.addPostFrameCallback((_) {
@@ -464,7 +467,10 @@ class _SubscriptionCardState extends State<SubscriptionCard>
],
),
const SizedBox(height: 6),
const SizedBox(height: 8),
_buildPaymentCardBadge(
context, paymentCardProvider),
const SizedBox(height: 8),
// 가격 정보
Row(
@@ -673,4 +679,57 @@ class _SubscriptionCardState extends State<SubscriptionCard>
),
);
}
Widget _buildPaymentCardBadge(
BuildContext context, PaymentCardProvider provider) {
final scheme = Theme.of(context).colorScheme;
final loc = AppLocalizations.of(context);
final card = provider.getCardById(widget.subscription.paymentCardId);
if (card == null) {
return Chip(
avatar: Icon(
Icons.credit_card_off_rounded,
size: 14,
color: scheme.onSurfaceVariant,
),
label: Text(
loc.paymentCardUnassigned,
style: TextStyle(
fontSize: 12,
color: scheme.onSurfaceVariant,
),
),
backgroundColor: scheme.surfaceContainerHighest.withValues(alpha: 0.5),
padding: const EdgeInsets.symmetric(horizontal: 6),
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
);
}
final color = PaymentCardUtils.colorFromHex(card.colorHex);
final icon = PaymentCardUtils.iconForName(card.iconName);
return Chip(
avatar: CircleAvatar(
backgroundColor: Colors.white,
child: Icon(
icon,
size: 14,
color: color,
),
),
label: Text(
'${card.issuerName} · ****${card.last4}',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: color,
),
),
side: BorderSide(color: color.withValues(alpha: 0.3)),
backgroundColor: color.withValues(alpha: 0.12),
padding: const EdgeInsets.symmetric(horizontal: 8),
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
);
}
}

View File

@@ -0,0 +1,164 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import '../l10n/app_localizations.dart';
import '../utils/payment_card_utils.dart';
import '../utils/subscription_grouping_helper.dart';
class SubscriptionGroupHeader extends StatelessWidget {
final SubscriptionGroupData group;
final int subscriptionCount;
final double totalCostUSD;
final double totalCostKRW;
final double totalCostJPY;
final double totalCostCNY;
const SubscriptionGroupHeader({
super.key,
required this.group,
required this.subscriptionCount,
required this.totalCostUSD,
required this.totalCostKRW,
required this.totalCostJPY,
required this.totalCostCNY,
});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Row(
children: [
if (group.mode == SubscriptionGroupingMode.paymentCard &&
group.paymentCard != null)
_PaymentCardAvatar(colorHex: group.paymentCard!.colorHex)
else if (group.mode == SubscriptionGroupingMode.paymentCard)
const _PaymentCardAvatar(),
if (group.mode == SubscriptionGroupingMode.paymentCard)
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
group.title,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: scheme.onSurface,
),
),
if (group.subtitle != null)
Text(
group.subtitle!,
style: TextStyle(
fontSize: 13,
color: scheme.onSurfaceVariant,
),
),
],
),
),
],
),
),
Text(
_buildCostDisplay(context),
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: scheme.onSurfaceVariant,
),
),
],
),
const SizedBox(height: 8),
Divider(
height: 1,
thickness: 1,
color: scheme.outline.withValues(alpha: 0.3),
),
],
),
);
}
String _buildCostDisplay(BuildContext context) {
final parts = <String>[];
parts
.add(AppLocalizations.of(context).subscriptionCount(subscriptionCount));
final currencyParts = <String>[];
if (totalCostUSD > 0) {
final formatter = NumberFormat.currency(
locale: 'en_US',
symbol: '\$',
decimalDigits: 2,
);
currencyParts.add(formatter.format(totalCostUSD));
}
if (totalCostKRW > 0) {
final formatter = NumberFormat.currency(
locale: 'ko_KR',
symbol: '',
decimalDigits: 0,
);
currencyParts.add(formatter.format(totalCostKRW));
}
if (totalCostJPY > 0) {
final formatter = NumberFormat.currency(
locale: 'ja_JP',
symbol: '¥',
decimalDigits: 0,
);
currencyParts.add(formatter.format(totalCostJPY));
}
if (totalCostCNY > 0) {
final formatter = NumberFormat.currency(
locale: 'zh_CN',
symbol: '¥',
decimalDigits: 2,
);
currencyParts.add(formatter.format(totalCostCNY));
}
if (currencyParts.isNotEmpty) {
parts.add(currencyParts.join(' + '));
}
return parts.join(' · ');
}
}
class _PaymentCardAvatar extends StatelessWidget {
final String? colorHex;
const _PaymentCardAvatar({this.colorHex});
@override
Widget build(BuildContext context) {
final color = colorHex != null
? PaymentCardUtils.colorFromHex(colorHex!)
: Theme.of(context).colorScheme.outlineVariant;
final icon =
colorHex != null ? Icons.credit_card : Icons.credit_card_off_rounded;
return CircleAvatar(
radius: 18,
backgroundColor: color.withValues(alpha: 0.15),
child: Icon(
icon,
color: color,
size: 16,
),
);
}
}

View File

@@ -1,66 +1,52 @@
import 'package:flutter/material.dart';
import '../models/subscription_model.dart';
import '../widgets/category_header_widget.dart';
import '../widgets/subscription_group_header.dart';
import '../widgets/swipeable_subscription_card.dart';
import '../widgets/staggered_list_animation.dart';
import '../widgets/app_navigator.dart';
import 'package:provider/provider.dart';
import '../providers/subscription_provider.dart';
import '../providers/locale_provider.dart';
import '../providers/category_provider.dart';
import '../services/subscription_url_matcher.dart';
import './dialogs/delete_confirmation_dialog.dart';
import './common/snackbar/app_snackbar.dart';
import '../l10n/app_localizations.dart';
import '../utils/logger.dart';
import '../utils/subscription_grouping_helper.dart';
/// 카테고리별로 구독 목록을 표시하는 위젯
class SubscriptionListWidget extends StatelessWidget {
final Map<String, List<SubscriptionModel>> categorizedSubscriptions;
final List<SubscriptionGroupData> groups;
final AnimationController fadeController;
const SubscriptionListWidget({
super.key,
required this.categorizedSubscriptions,
required this.groups,
required this.fadeController,
});
@override
Widget build(BuildContext context) {
// 카테고리 키 목록 (정렬된)
final categories = categorizedSubscriptions.keys.toList();
final sections = groups;
return SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
final category = categories[index];
final subscriptions = categorizedSubscriptions[category]!;
final group = sections[index];
final subscriptions = group.subscriptions;
return Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 카테고리 헤더
Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 8),
child: Consumer<CategoryProvider>(
builder: (context, categoryProvider, child) {
return CategoryHeaderWidget(
categoryName: categoryProvider.getLocalizedCategoryName(
context, category),
subscriptionCount: subscriptions.length,
totalCostUSD:
_calculateTotalByCurrency(subscriptions, 'USD'),
totalCostKRW:
_calculateTotalByCurrency(subscriptions, 'KRW'),
totalCostJPY:
_calculateTotalByCurrency(subscriptions, 'JPY'),
totalCostCNY:
_calculateTotalByCurrency(subscriptions, 'CNY'),
);
},
),
SubscriptionGroupHeader(
group: group,
subscriptionCount: subscriptions.length,
totalCostUSD: _calculateTotalByCurrency(subscriptions, 'USD'),
totalCostKRW: _calculateTotalByCurrency(subscriptions, 'KRW'),
totalCostJPY: _calculateTotalByCurrency(subscriptions, 'JPY'),
totalCostCNY: _calculateTotalByCurrency(subscriptions, 'CNY'),
),
// 카테고리별 구독 목록
FadeTransition(
@@ -169,7 +155,7 @@ class SubscriptionListWidget extends StatelessWidget {
),
);
},
childCount: categories.length,
childCount: sections.length,
),
);
}