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/billing_cost_util.dart'; import '../utils/payment_card_utils.dart'; import 'website_icon.dart'; import 'app_navigator.dart'; // import '../theme/app_colors.dart'; import '../theme/color_scheme_ext.dart'; // import 'glassmorphism_card.dart'; import '../l10n/app_localizations.dart'; class SubscriptionCard extends StatefulWidget { final SubscriptionModel subscription; final VoidCallback? onTap; const SubscriptionCard({ super.key, required this.subscription, this.onTap, }); @override State createState() => _SubscriptionCardState(); } class _SubscriptionCardState extends State with SingleTickerProviderStateMixin { late AnimationController _hoverController; bool _isHovering = false; String? _displayName; static const int _nearBillingThresholdDays = 3; @override void initState() { super.initState(); _hoverController = AnimationController( vsync: this, duration: const Duration(milliseconds: 200), ); _loadDisplayName(); } Future _loadDisplayName() async { if (!mounted) return; final localeProvider = context.read(); final locale = localeProvider.locale.languageCode; final displayName = await SubscriptionUrlMatcher.getServiceDisplayName( serviceName: widget.subscription.serviceName, locale: locale, ); if (mounted) { setState(() { _displayName = displayName; }); } } @override void didUpdateWidget(SubscriptionCard oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.subscription.serviceName != widget.subscription.serviceName) { _loadDisplayName(); } } @override void dispose() { _hoverController.dispose(); super.dispose(); } void _onHover(bool isHovering) { setState(() { _isHovering = isHovering; if (isHovering) { _hoverController.forward(); } else { _hoverController.reverse(); } }); } // 다음 결제 예정일 정보를 생성 String _getNextBillingText() { final now = DateTime.now(); final nextBillingDate = widget.subscription.nextBillingDate; // 날짜 비교를 위해 시간 제거 (날짜만 비교) final dateOnlyNow = DateTime(now.year, now.month, now.day); final dateOnlyBilling = DateTime( nextBillingDate.year, nextBillingDate.month, nextBillingDate.day); // 오늘이 결제일인 경우 if (dateOnlyNow.isAtSameMomentAs(dateOnlyBilling)) { return AppLocalizations.of(context).paymentDueToday; } // 미래 날짜인 경우 남은 일수 계산 if (dateOnlyBilling.isAfter(dateOnlyNow)) { final difference = dateOnlyBilling.difference(dateOnlyNow).inDays; return AppLocalizations.of(context).paymentDueInDays(difference); } // 과거 날짜인 경우, 다음 결제일 계산 final billingCycle = widget.subscription.billingCycle; final norm = BillingDateUtil.normalizeCycle(billingCycle); // 분기/반기 구독 처리 if (norm == 'quarterly' || norm == 'half-yearly') { final nextDate = BillingDateUtil.ensureFutureDate(nextBillingDate, billingCycle); final days = nextDate.difference(dateOnlyNow).inDays; if (days == 0) return AppLocalizations.of(context).paymentDueToday; return AppLocalizations.of(context).paymentDueInDays(days); } // 월간 구독인 경우 if (SubscriptionModel.normalizeBillingCycle(billingCycle) == 'monthly') { // 결제일에 해당하는 날짜 가져오기 int day = nextBillingDate.day; int nextMonth = now.month; int nextYear = now.year; // 해당 월의 마지막 날짜 확인 (예: 31일이 없는 달) final lastDayOfMonth = DateTime(now.year, now.month + 1, 0).day; if (day > lastDayOfMonth) { day = lastDayOfMonth; } // 결제일이 이번 달에서 이미 지났으면 다음 달로 설정 if (now.day > day) { nextMonth++; if (nextMonth > 12) { nextMonth = 1; nextYear++; } // 다음 달의 마지막 날짜 확인 final lastDayOfNextMonth = DateTime(nextYear, nextMonth + 1, 0).day; if (day > lastDayOfNextMonth) { day = lastDayOfNextMonth; } } final nextDate = DateTime(nextYear, nextMonth, day); final days = nextDate.difference(dateOnlyNow).inDays; if (days == 0) return AppLocalizations.of(context).paymentDueToday; return AppLocalizations.of(context).paymentDueInDays(days); } // 연간 구독인 경우 if (SubscriptionModel.normalizeBillingCycle(billingCycle) == 'yearly') { // 결제일에 해당하는 날짜와 월 가져오기 int day = nextBillingDate.day; int month = nextBillingDate.month; int year = now.year; // 해당 월의 마지막 날짜 확인 final lastDayOfMonth = DateTime(year, month + 1, 0).day; if (day > lastDayOfMonth) { day = lastDayOfMonth; } // 올해의 결제일 final thisYearDate = DateTime(year, month, day); // 올해 결제일이 이미 지났으면 내년으로 계산 if (thisYearDate.isBefore(dateOnlyNow) || thisYearDate.isAtSameMomentAs(dateOnlyNow)) { year++; // 내년 해당 월의 마지막 날짜 확인 final lastDayOfNextYear = DateTime(year, month + 1, 0).day; if (day > lastDayOfNextYear) { day = lastDayOfNextYear; } final nextYearDate = DateTime(year, month, day); final days = nextYearDate.difference(dateOnlyNow).inDays; if (days == 0) return AppLocalizations.of(context).paymentDueToday; return AppLocalizations.of(context).paymentDueInDays(days); } else { final days = thisYearDate.difference(dateOnlyNow).inDays; if (days == 0) return AppLocalizations.of(context).paymentDueToday; return AppLocalizations.of(context).paymentDueInDays(days); } } // 주간 구독인 경우 if (SubscriptionModel.normalizeBillingCycle(billingCycle) == 'weekly') { // 결제 요일 가져오기 final billingWeekday = nextBillingDate.weekday; // 현재 요일 final currentWeekday = now.weekday; // 다음 같은 요일까지 남은 일수 계산 int daysUntilNext; if (currentWeekday < billingWeekday) { daysUntilNext = billingWeekday - currentWeekday; } else if (currentWeekday > billingWeekday) { daysUntilNext = 7 - (currentWeekday - billingWeekday); } else { // 같은 요일 daysUntilNext = 7; // 다음 주 같은 요일 } if (daysUntilNext == 0) { return AppLocalizations.of(context).paymentDueToday; } return AppLocalizations.of(context).paymentDueInDays(daysUntilNext); } // 기본값 - 예상할 수 없는 경우 return AppLocalizations.of(context).paymentInfoNeeded; } int _daysUntilNextBilling() { final now = DateTime.now(); final dateOnlyNow = DateTime(now.year, now.month, now.day); final nbd = widget.subscription.nextBillingDate; final dateOnlyBilling = DateTime(nbd.year, nbd.month, nbd.day); if (dateOnlyBilling.isAfter(dateOnlyNow)) { return dateOnlyBilling.difference(dateOnlyNow).inDays; } final next = BillingDateUtil.ensureFutureDate(nbd, widget.subscription.billingCycle); return next.difference(dateOnlyNow).inDays; } // 결제일이 가까운지 확인 bool _isNearBilling() { final days = _daysUntilNextBilling(); return days <= _nearBillingThresholdDays; } // 카테고리별 그라데이션 색상 생성 List _getCategoryGradientColors(BuildContext context) { try { if (widget.subscription.categoryId == null) { return [Theme.of(context).colorScheme.primary]; } final categoryProvider = context.watch(); final category = categoryProvider.getCategoryById(widget.subscription.categoryId!); if (category == null) { return [Theme.of(context).colorScheme.primary]; } final categoryColor = Color(int.parse(category.color.replaceAll('#', '0xFF'))); return [categoryColor]; } catch (e) { // 색상 파싱 실패 시 기본 primary 색 반환 return [Theme.of(context).colorScheme.primary]; } } // 가격 포맷팅 함수 (언어별 통화) - 실제 결제 금액 표시 Future _getFormattedPrice() async { final locale = context.read().locale.languageCode; final billingCycle = widget.subscription.billingCycle; if (widget.subscription.isCurrentlyInEvent) { // 이벤트 중인 경우: 월 비용을 실제 결제 금액으로 역변환 final actualOriginalPrice = BillingCostUtil.convertFromMonthlyCost( widget.subscription.monthlyCost, billingCycle, ); final actualCurrentPrice = BillingCostUtil.convertFromMonthlyCost( widget.subscription.currentPrice, billingCycle, ); final originalPrice = await CurrencyUtil.formatAmountWithLocale( actualOriginalPrice, widget.subscription.currency, locale, ); final currentPrice = await CurrencyUtil.formatAmountWithLocale( actualCurrentPrice, widget.subscription.currency, locale, ); return '$originalPrice|$currentPrice'; } else { // 월 비용을 실제 결제 금액으로 역변환 (연간이면 x12, 분기면 x3 등) final actualPrice = BillingCostUtil.convertFromMonthlyCost( widget.subscription.currentPrice, billingCycle, ); return CurrencyUtil.formatAmountWithLocale( actualPrice, widget.subscription.currency, locale, ); } } @override Widget build(BuildContext context) { // LocaleProvider를 watch하여 언어 변경시 자동 업데이트 final localeProvider = context.watch(); final paymentCardProvider = context.watch(); // 언어가 변경되면 displayName 다시 로드 WidgetsBinding.instance.addPostFrameCallback((_) { _loadDisplayName(); }); final isNearBilling = _isNearBilling(); return Hero( tag: 'subscription_${widget.subscription.id}', child: MouseRegion( onEnter: (_) => _onHover(true), onExit: (_) => _onHover(false), child: Card( elevation: _isHovering ? 2 : 1, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16), side: BorderSide( color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.4), width: 1, ), ), clipBehavior: Clip.antiAlias, child: InkWell( onTap: widget.onTap ?? () async { // ignore: use_build_context_synchronously await AppNavigator.toDetail(context, widget.subscription); }, child: Column( children: [ // 그라데이션 상단 바 효과 AnimatedContainer( duration: const Duration(milliseconds: 200), height: 4, // 카테고리 우선: 상단 바는 항상 카테고리 색 color: _getCategoryGradientColors(context).first, ), Padding( padding: const EdgeInsets.all(16), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ // 서비스 아이콘 WebsiteIcon( key: ValueKey( 'subscription_icon_${widget.subscription.id}'), url: widget.subscription.websiteUrl, serviceName: widget.subscription.serviceName, size: 48, isHovered: _isHovering, ), const SizedBox(width: 16), // 서비스 정보 Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ // 서비스명 Flexible( child: Text( _displayName ?? widget.subscription.serviceName, style: TextStyle( fontWeight: FontWeight.w600, fontSize: 18, color: Theme.of(context) .colorScheme .onSurface, ), maxLines: 1, overflow: TextOverflow.ellipsis, ), ), // 배지들 Row( mainAxisSize: MainAxisSize.min, children: [ // 이벤트 배지 if (widget .subscription.isCurrentlyInEvent) ...[ Container( padding: const EdgeInsets.symmetric( horizontal: 8, vertical: 3, ), decoration: BoxDecoration( color: Theme.of(context) .colorScheme .error, borderRadius: BorderRadius.circular(12), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon( Icons.local_offer_rounded, size: 11, color: Theme.of(context) .colorScheme .onError, ), const SizedBox(width: 3), Text( AppLocalizations.of(context) .event, style: TextStyle( fontSize: 11, fontWeight: FontWeight.w600, color: Theme.of(context) .colorScheme .onError, ), ), ], ), ), const SizedBox(width: 6), ], // 결제 주기 배지 Container( padding: const EdgeInsets.symmetric( horizontal: 8, vertical: 3, ), decoration: BoxDecoration( color: Theme.of(context) .colorScheme .surface, borderRadius: BorderRadius.circular(12), border: Border.all( color: Theme.of(context) .colorScheme .outline .withValues(alpha: 0.5), width: 0.5, ), ), child: Text( AppLocalizations.of(context) .getBillingCycleName(widget .subscription.billingCycle), style: TextStyle( fontSize: 11, fontWeight: FontWeight.w600, color: Theme.of(context) .colorScheme .onSurfaceVariant, ), ), ), ], ), ], ), const SizedBox(height: 8), _buildPaymentCardBadge( context, paymentCardProvider), const SizedBox(height: 8), // 가격 정보 Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ // 가격 표시 (이벤트 가격 반영) Expanded( child: FutureBuilder( future: _getFormattedPrice(), builder: (context, snapshot) { if (!snapshot.hasData) { return const SizedBox(); } if (widget.subscription .isCurrentlyInEvent && snapshot.data!.contains('|')) { final prices = snapshot.data!.split('|'); return Wrap( spacing: 8, runSpacing: 4, crossAxisAlignment: WrapCrossAlignment.center, children: [ Text( prices[0], style: TextStyle( fontSize: 14, fontWeight: FontWeight.w500, color: Theme.of(context) .colorScheme .onSurfaceVariant, decoration: TextDecoration.lineThrough, ), ), Text( prices[1], style: TextStyle( fontSize: 16, fontWeight: FontWeight.w700, color: Theme.of(context) .colorScheme .error, ), ), ], ); } else { return Text( snapshot.data!, maxLines: 2, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 16, fontWeight: FontWeight.w700, color: widget.subscription .isCurrentlyInEvent ? Theme.of(context) .colorScheme .error : Theme.of(context) .colorScheme .primary, ), ); } }, ), ), const SizedBox(width: 12), // 결제 예정일 정보 Container( padding: const EdgeInsets.symmetric( horizontal: 8, vertical: 3, ), decoration: BoxDecoration( color: (isNearBilling ? Theme.of(context) .colorScheme .warning : Theme.of(context) .colorScheme .success) .withValues(alpha: 0.1), borderRadius: BorderRadius.circular(12), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon( isNearBilling ? Icons.access_time_filled_rounded : Icons.check_circle_rounded, size: 12, color: isNearBilling ? Theme.of(context) .colorScheme .warning : Theme.of(context) .colorScheme .success, ), const SizedBox(width: 4), Text( _getNextBillingText(), style: TextStyle( fontSize: 11, fontWeight: FontWeight.w600, color: isNearBilling ? Theme.of(context) .colorScheme .warning : Theme.of(context) .colorScheme .success, ), ), ], ), ), ], ), // 이벤트 절약액 표시 if (widget.subscription.isCurrentlyInEvent && widget.subscription.eventSavings > 0) ...[ const SizedBox(height: 4), Row( children: [ Container( padding: const EdgeInsets.symmetric( horizontal: 6, vertical: 2, ), decoration: BoxDecoration( color: Theme.of(context) .colorScheme .error .withValues(alpha: 0.1), borderRadius: BorderRadius.circular(8), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon( Icons.savings_rounded, size: 14, color: Theme.of(context) .colorScheme .error, ), const SizedBox(width: 4), // 이벤트 절약액 표시 (언어별 통화) FutureBuilder( future: CurrencyUtil .formatEventSavingsWithLocale( widget.subscription, localeProvider.locale.languageCode, ), builder: (context, snapshot) { if (!snapshot.hasData) { return const SizedBox(); } return Text( '${snapshot.data!} ${AppLocalizations.of(context).saving}', style: TextStyle( fontSize: 12, fontWeight: FontWeight.w600, color: Theme.of(context) .colorScheme .error, ), ); }, ), ], ), ), const SizedBox(width: 8), // 이벤트 종료일까지 남은 일수 if (widget.subscription.eventEndDate != null) ...[ Text( AppLocalizations.of(context) .daysRemaining(widget .subscription.eventEndDate! .difference(DateTime.now()) .inDays), style: TextStyle( fontSize: 11, color: Theme.of(context) .colorScheme .onSurfaceVariant, ), ), ], ], ), ], ], ), ), ], ), ), ], ), ), ), ), ); } 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: Container( width: 20, height: 20, decoration: BoxDecoration( color: color.withValues(alpha: 0.15), shape: BoxShape.circle, ), alignment: Alignment.center, child: Icon( icon, size: 12, 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, ); } }