Files
submanager/lib/widgets/sms_scan/subscription_card_widget.dart
2025-12-07 21:14:54 +09:00

517 lines
17 KiB
Dart

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';
import '../../widgets/common/buttons/primary_button.dart';
import '../../widgets/common/buttons/secondary_button.dart';
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/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';
import '../../l10n/app_localizations.dart';
class SubscriptionCardWidget extends StatefulWidget {
final Subscription subscription;
final TextEditingController serviceNameController;
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;
final bool enableServiceNameEditing;
final ValueChanged<String>? onServiceNameChanged;
const SubscriptionCardWidget({
super.key,
required this.subscription,
required this.serviceNameController,
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,
this.enableServiceNameEditing = false,
this.onServiceNameChanged,
});
@override
State<SubscriptionCardWidget> createState() => _SubscriptionCardWidgetState();
}
class _SubscriptionCardWidgetState extends State<SubscriptionCardWidget> {
@override
void initState() {
super.initState();
// URL 필드 자동 설정
if (widget.websiteUrlController.text.isEmpty &&
widget.subscription.websiteUrl != null) {
widget.websiteUrlController.text = widget.subscription.websiteUrl!;
}
}
void _handleCardTap() {
// 구독 카드 클릭 시 처리
AppSnackBar.showInfo(
context: context,
message: AppLocalizations.of(context).featureComingSoon,
icon: Icons.info_outline,
);
}
@override
Widget build(BuildContext context) {
final categoryProvider = Provider.of<CategoryProvider>(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (_hasRawSmsMessage)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: _buildSmsPreviewCard(context),
),
if (_hasRawSmsMessage) const SizedBox(height: 16),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// 구독 정보 카드
Card(
elevation: 1,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16.0),
side: BorderSide(
color: Theme.of(context)
.colorScheme
.outline
.withValues(alpha: 0.4),
),
),
child: Column(
children: [
// 클릭 가능한 정보 영역
Material(
color: Colors.transparent,
child: InkWell(
onTap: _handleCardTap,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(16.0),
topRight: Radius.circular(16.0),
),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: _buildInfoSection(categoryProvider),
),
),
),
// 구분선
Container(
height: 1,
color: Theme.of(context)
.colorScheme
.onSurfaceVariant
.withValues(alpha: 0.1),
),
// 클릭 불가능한 액션 영역
Padding(
padding: const EdgeInsets.all(16.0),
child: _buildActionSection(categoryProvider),
),
],
),
),
],
),
),
],
);
}
bool get _hasRawSmsMessage {
return widget.subscription.rawMessage != null &&
widget.subscription.rawMessage!.trim().isNotEmpty;
}
Widget _buildSmsPreviewCard(BuildContext context) {
final theme = Theme.of(context);
final loc = AppLocalizations.of(context);
final rawMessage = widget.subscription.rawMessage?.trim() ?? '';
final lastDate = widget.subscription.lastPaymentDate;
return Card(
elevation: 0,
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.4),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.sms_outlined, color: theme.colorScheme.primary),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ThemedText(
loc.latestSmsMessage,
fontWeight: FontWeight.bold,
),
if (lastDate != null)
ThemedText(
loc.smsDetectedDate(loc.formatDate(lastDate)),
opacity: 0.7,
fontSize: 13,
),
],
),
),
],
),
const SizedBox(height: 12),
Container(
width: double.infinity,
decoration: BoxDecoration(
color: theme.colorScheme.surface,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: theme.colorScheme.outline.withValues(alpha: 0.3),
),
),
padding: const EdgeInsets.all(12),
child: SelectableText(
rawMessage,
style: TextStyle(
fontSize: 15,
height: 1.4,
color: theme.colorScheme.onSurface,
),
),
),
],
),
),
);
}
// 정보 섹션 (클릭 가능)
Widget _buildInfoSection(CategoryProvider categoryProvider) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ThemedText(
AppLocalizations.of(context).foundSubscription,
fontSize: 18,
fontWeight: FontWeight.bold,
),
const SizedBox(height: 24),
// 서비스명
ThemedText(
AppLocalizations.of(context).serviceName,
fontWeight: FontWeight.w500,
opacity: 0.7,
),
const SizedBox(height: 4),
if (widget.enableServiceNameEditing)
BaseTextField(
controller: widget.serviceNameController,
hintText: AppLocalizations.of(context).serviceNameRequired,
onChanged: widget.onServiceNameChanged,
textInputAction: TextInputAction.done,
maxLines: 1,
)
else
ThemedText(
widget.subscription.serviceName,
fontSize: 22,
fontWeight: FontWeight.bold,
),
const SizedBox(height: 16),
// 금액 및 결제 주기
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ThemedText(
AppLocalizations.of(context).monthlyCost,
fontWeight: FontWeight.w500,
opacity: 0.7,
),
const SizedBox(height: 4),
// 언어별 통화 표시
FutureBuilder<String>(
future: CurrencyUtil.formatAmountWithLocale(
widget.subscription.monthlyCost,
widget.subscription.currency,
context.read<LocaleProvider>().locale.languageCode,
),
builder: (context, snapshot) {
return ThemedText(
snapshot.data ?? '-',
fontSize: 18,
fontWeight: FontWeight.bold,
);
},
),
],
),
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ThemedText(
AppLocalizations.of(context).billingCycle,
fontWeight: FontWeight.w500,
opacity: 0.7,
),
const SizedBox(height: 4),
ThemedText(
widget.subscription.billingCycle,
fontSize: 16,
fontWeight: FontWeight.w500,
),
],
),
),
],
),
const SizedBox(height: 16),
// 다음 결제일
ThemedText(
AppLocalizations.of(context).nextBillingDateLabel,
fontWeight: FontWeight.w500,
opacity: 0.7,
),
const SizedBox(height: 4),
ThemedText(
SmsDateFormatter.getNextBillingText(
context,
widget.subscription.nextBillingDate,
widget.subscription.billingCycle,
),
fontSize: 16,
fontWeight: FontWeight.w500,
),
],
);
}
// 액션 섹션 (클릭 불가능)
Widget _buildActionSection(CategoryProvider categoryProvider) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 카테고리 선택
ThemedText(
AppLocalizations.of(context).category,
fontWeight: FontWeight.w500,
opacity: 0.7,
),
const SizedBox(height: 8),
CategorySelector(
categories: categoryProvider.categories,
selectedCategoryId:
widget.selectedCategoryId ?? widget.subscription.category,
onChanged: widget.onCategoryChanged,
baseColor: _getCategoryColor(categoryProvider),
),
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,
label: AppLocalizations.of(context).websiteUrlAuto,
hintText: AppLocalizations.of(context).websiteUrlHint,
prefixIcon: Icon(
Icons.language,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface,
),
fillColor: Theme.of(context).colorScheme.surface,
),
const SizedBox(height: 32),
// 작업 버튼
Row(
children: [
Expanded(
child: SecondaryButton(
text: AppLocalizations.of(context).skip,
onPressed: widget.onSkip,
height: 48,
),
),
const SizedBox(width: 16),
Expanded(
child: PrimaryButton(
text: AppLocalizations.of(context).add,
onPressed: widget.onAdd,
height: 48,
),
),
],
),
],
);
}
Color? _getCategoryColor(CategoryProvider categoryProvider) {
final categoryId =
widget.selectedCategoryId ?? widget.subscription.category;
if (categoryId == null) return null;
final category = categoryProvider.getCategoryById(categoryId);
if (category == null) return null;
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),
),
],
),
);
}
}