import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import 'package:provider/provider.dart'; import '../models/subscription_model.dart'; import '../providers/category_provider.dart'; import '../providers/locale_provider.dart'; import '../services/subscription_url_matcher.dart'; import '../services/currency_util.dart'; import 'website_icon.dart'; import 'app_navigator.dart'; import '../theme/app_colors.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; @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; // 월간 구독인 경우 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; } // 결제일이 가까운지 확인 (7일 이내) bool _isNearBilling() { final text = _getNextBillingText(); if (text == AppLocalizations.of(context).paymentDueToday) return true; final regex = RegExp(r'(\d+)'); final match = regex.firstMatch(text); if (match != null) { final days = int.parse(match.group(1) ?? '0'); return days <= 7; } return false; } // 카테고리별 그라데이션 색상 생성 List _getCategoryGradientColors(BuildContext context) { try { if (widget.subscription.categoryId == null) { return AppColors.blueGradient; } final categoryProvider = context.watch(); final category = categoryProvider.getCategoryById(widget.subscription.categoryId!); if (category == null) { return AppColors.blueGradient; } final categoryColor = Color( int.parse(category.color.replaceAll('#', '0xFF')) ); return [ categoryColor, categoryColor.withValues(alpha: 0.8), ]; } catch (e) { // 색상 파싱 실패 시 기본 파란색 그라데이션 반환 return AppColors.blueGradient; } } // 가격 포맷팅 함수 (언어별 통화) Future _getFormattedPrice() async { final locale = context.read().locale.languageCode; if (widget.subscription.isCurrentlyInEvent) { // 이벤트 중인 경우 원래 가격과 현재 가격 모두 표시 final originalPrice = await CurrencyUtil.formatAmountWithLocale( widget.subscription.monthlyCost, widget.subscription.currency, locale, ); final currentPrice = await CurrencyUtil.formatAmountWithLocale( widget.subscription.currentPrice, widget.subscription.currency, locale, ); return '$originalPrice|$currentPrice'; } else { return CurrencyUtil.formatAmountWithLocale( widget.subscription.currentPrice, widget.subscription.currency, locale, ); } } @override Widget build(BuildContext context) { // LocaleProvider를 watch하여 언어 변경시 자동 업데이트 final localeProvider = 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: AnimatedGlassmorphismCard( padding: EdgeInsets.zero, borderRadius: 16, blur: _isHovering ? 15 : 10, width: double.infinity, // 전체 너비를 차지하도록 설정 onTap: widget.onTap ?? () async { print('[SubscriptionCard] AnimatedGlassmorphismCard onTap 호출됨 - ${widget.subscription.serviceName}'); await AppNavigator.toDetail(context, widget.subscription); }, child: Column( children: [ // 그라데이션 상단 바 효과 AnimatedContainer( duration: const Duration(milliseconds: 200), height: 4, decoration: BoxDecoration( gradient: LinearGradient( colors: widget.subscription.isCurrentlyInEvent ? [ const Color(0xFFFF6B6B), const Color(0xFFFF8787), ] : isNearBilling ? AppColors.amberGradient : _getCategoryGradientColors(context), begin: Alignment.centerLeft, end: Alignment.centerRight, ), ), ), 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: const TextStyle( fontWeight: FontWeight.w600, fontSize: 18, color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트 ), 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( gradient: const LinearGradient( colors: [ Color(0xFFFF6B6B), Color(0xFFFF8787), ], ), borderRadius: BorderRadius.circular(12), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ const Icon( Icons.local_offer_rounded, size: 11, color: AppColors.pureWhite, ), const SizedBox(width: 3), Text( AppLocalizations.of(context).event, style: const TextStyle( fontSize: 11, fontWeight: FontWeight.w600, color: AppColors.pureWhite, ), ), ], ), ), const SizedBox(width: 6), ], // 결제 주기 배지 Container( padding: const EdgeInsets.symmetric( horizontal: 8, vertical: 3, ), decoration: BoxDecoration( color: AppColors.surfaceColorAlt, borderRadius: BorderRadius.circular(12), border: Border.all( color: AppColors.borderColor, width: 0.5, ), ), child: Text( AppLocalizations.of(context).getBillingCycleName(widget.subscription.billingCycle), style: const TextStyle( fontSize: 11, fontWeight: FontWeight.w600, color: AppColors.navyGray, // color.md 가이드: 서브 텍스트 ), ), ), ], ), ], ), const SizedBox(height: 6), // 가격 정보 Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ // 가격 표시 (이벤트 가격 반영) // 가격 표시 (언어별 통화) 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 Row( children: [ Text( prices[0], style: const TextStyle( fontSize: 14, fontWeight: FontWeight.w500, color: AppColors.navyGray, decoration: TextDecoration.lineThrough, ), ), const SizedBox(width: 8), Text( prices[1], style: const TextStyle( fontSize: 16, fontWeight: FontWeight.w700, color: Color(0xFFFF6B6B), ), ), ], ); } else { return Text( snapshot.data!, style: TextStyle( fontSize: 16, fontWeight: FontWeight.w700, color: widget.subscription.isCurrentlyInEvent ? const Color(0xFFFF6B6B) : AppColors.primaryColor, ), ); } }, ), // 결제 예정일 정보 Container( padding: const EdgeInsets.symmetric( horizontal: 8, vertical: 3, ), decoration: BoxDecoration( color: isNearBilling ? AppColors.warningColor .withValues(alpha: 0.1) : AppColors.successColor .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 ? AppColors.warningColor : AppColors.successColor, ), const SizedBox(width: 4), Text( _getNextBillingText(), style: TextStyle( fontSize: 11, fontWeight: FontWeight.w600, color: isNearBilling ? AppColors.warningColor : AppColors.successColor, ), ), ], ), ), ], ), // 이벤트 절약액 표시 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: const Color(0xFFFF6B6B).withValues(alpha: 0.1), borderRadius: BorderRadius.circular(8), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ const Icon( Icons.savings_rounded, size: 14, color: Color(0xFFFF6B6B), ), 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: const TextStyle( fontSize: 12, fontWeight: FontWeight.w600, color: Color(0xFFFF6B6B), ), ); }, ), ], ), ), const SizedBox(width: 8), // 이벤트 종료일까지 남은 일수 if (widget.subscription.eventEndDate != null) ...[ Text( AppLocalizations.of(context).daysRemaining(widget.subscription.eventEndDate!.difference(DateTime.now()).inDays), style: const TextStyle( fontSize: 11, color: AppColors.navyGray, // color.md 가이드: 서브 텍스트 ), ), ], ], ), ], ], ), ), ], ), ), ], ), ), ), ); } }