import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import 'dart:math' as math; import '../models/subscription_model.dart'; import '../screens/detail_screen.dart'; import 'website_icon.dart'; import '../theme/app_colors.dart'; import 'package:provider/provider.dart'; import '../providers/subscription_provider.dart'; class SubscriptionCard extends StatefulWidget { final SubscriptionModel subscription; const SubscriptionCard({ super.key, required this.subscription, }); @override State createState() => _SubscriptionCardState(); } class _SubscriptionCardState extends State with SingleTickerProviderStateMixin { late AnimationController _hoverController; bool _isHovering = false; final double _initialElevation = 1.0; final double _hoveredElevation = 3.0; late SubscriptionProvider _subscriptionProvider; @override void initState() { super.initState(); _hoverController = AnimationController( vsync: this, duration: const Duration(milliseconds: 200), ); } @override void didChangeDependencies() { super.didChangeDependencies(); _subscriptionProvider = Provider.of(context, listen: false); } @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 '오늘 결제 예정'; } // 미래 날짜인 경우 남은 일수 계산 if (dateOnlyBilling.isAfter(dateOnlyNow)) { final difference = dateOnlyBilling.difference(dateOnlyNow).inDays; return '$difference일 후 결제 예정'; } // 과거 날짜인 경우, 다음 결제일 계산 final billingCycle = widget.subscription.billingCycle; // 월간 구독인 경우 if (billingCycle == '월간') { // 결제일에 해당하는 날짜 가져오기 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 '오늘 결제 예정'; return '$days일 후 결제 예정'; } // 연간 구독인 경우 if (billingCycle == '연간') { // 결제일에 해당하는 날짜와 월 가져오기 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 '오늘 결제 예정'; return '$days일 후 결제 예정'; } else { final days = thisYearDate.difference(dateOnlyNow).inDays; if (days == 0) return '오늘 결제 예정'; return '$days일 후 결제 예정'; } } // 주간 구독인 경우 if (billingCycle == '주간') { // 결제 요일 가져오기 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 '오늘 결제 예정'; return '$daysUntilNext일 후 결제 예정'; } // 기본값 - 예상할 수 없는 경우 return '결제일 정보 필요'; } // 결제일이 가까운지 확인 (7일 이내) bool _isNearBilling() { final text = _getNextBillingText(); if (text == '오늘 결제 예정') 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; } Color _getCardColor() { return Colors.white; } @override Widget build(BuildContext context) { final isNearBilling = _isNearBilling(); final Color cardColor = _getCardColor(); return Hero( tag: 'subscription_${widget.subscription.id}', child: MouseRegion( onEnter: (_) => _onHover(true), onExit: (_) => _onHover(false), child: AnimatedBuilder( animation: _hoverController, builder: (context, child) { final elevation = _initialElevation + (_hoveredElevation - _initialElevation) * _hoverController.value; final scale = 1.0 + (0.02 * _hoverController.value); return Transform.scale( scale: scale, child: Material( color: Colors.transparent, child: InkWell( onTap: () async { final result = await Navigator.push( context, PageRouteBuilder( pageBuilder: (context, animation, secondaryAnimation) => DetailScreen(subscription: widget.subscription), transitionsBuilder: (context, animation, secondaryAnimation, child) { const begin = Offset(0.0, 0.05); const end = Offset.zero; const curve = Curves.easeOutCubic; var tween = Tween(begin: begin, end: end) .chain(CurveTween(curve: curve)); var fadeAnimation = Tween(begin: 0.6, end: 1.0) .chain(CurveTween(curve: curve)) .animate(animation); return FadeTransition( opacity: fadeAnimation, child: SlideTransition( position: animation.drive(tween), child: child, ), ); }, ), ); if (result == true) { // 변경 사항이 있을 경우 미리 저장된 Provider 참조를 사용하여 구독 목록 갱신 await _subscriptionProvider.refreshSubscriptions(); // 메인 화면의 State를 갱신하기 위해 미세한 지연 후 다시 한번 알림 // mounted 상태를 확인하여 dispose된 위젯에서 Provider를 참조하지 않도록 합니다. Future.delayed(const Duration(milliseconds: 100), () { // 위젯이 아직 마운트 상태인지 확인하고, 미리 저장된 Provider 참조 사용 if (mounted) { _subscriptionProvider.notifyListeners(); } }); } }, splashColor: AppColors.primaryColor.withOpacity(0.1), highlightColor: AppColors.primaryColor.withOpacity(0.05), borderRadius: BorderRadius.circular(16), child: Container( clipBehavior: Clip.antiAlias, decoration: BoxDecoration( color: cardColor, borderRadius: BorderRadius.circular(16), border: Border.all( color: _isHovering ? AppColors.primaryColor.withOpacity(0.3) : AppColors.borderColor, width: _isHovering ? 1.5 : 0.5, ), boxShadow: [ BoxShadow( color: AppColors.primaryColor.withOpacity( 0.03 + (0.05 * _hoverController.value)), blurRadius: 8 + (8 * _hoverController.value), spreadRadius: 0, offset: Offset(0, 4 + (2 * _hoverController.value)), ), ], ), 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 : AppColors.blueGradient, 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( widget.subscription.serviceName, style: const TextStyle( fontWeight: FontWeight.w600, fontSize: 18, color: Color(0xFF1E293B), ), 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: LinearGradient( colors: [ const Color(0xFFFF6B6B), const Color(0xFFFF8787), ], ), borderRadius: BorderRadius.circular(12), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ const Icon( Icons.local_offer_rounded, size: 11, color: Colors.white, ), const SizedBox(width: 3), Text( '이벤트', style: TextStyle( fontSize: 11, fontWeight: FontWeight.w600, color: Colors.white, ), ), ], ), ), 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( widget.subscription.billingCycle, style: TextStyle( fontSize: 11, fontWeight: FontWeight.w600, color: AppColors.textSecondary, ), ), ), ], ), ], ), const SizedBox(height: 6), // 가격 정보 Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ // 가격 표시 (이벤트 가격 반영) Row( children: [ // 이벤트 중인 경우 원래 가격을 취소선으로 표시 if (widget.subscription.isCurrentlyInEvent) ...[ Text( widget.subscription.currency == 'USD' ? NumberFormat.currency( locale: 'en_US', symbol: '\$', decimalDigits: 2, ).format(widget .subscription.monthlyCost) : NumberFormat.currency( locale: 'ko_KR', symbol: '₩', decimalDigits: 0, ).format(widget .subscription.monthlyCost), style: TextStyle( fontSize: 14, fontWeight: FontWeight.w500, color: AppColors.textSecondary, decoration: TextDecoration.lineThrough, ), ), const SizedBox(width: 8), ], // 현재 가격 (이벤트 또는 정상 가격) Text( widget.subscription.currency == 'USD' ? NumberFormat.currency( locale: 'en_US', symbol: '\$', decimalDigits: 2, ).format(widget .subscription.currentPrice) : NumberFormat.currency( locale: 'ko_KR', symbol: '₩', decimalDigits: 0, ).format(widget .subscription.currentPrice), 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 .withOpacity(0.1) : AppColors.successColor .withOpacity(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).withOpacity(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), Text( widget.subscription.currency == 'USD' ? '${NumberFormat.currency( locale: 'en_US', symbol: '\$', decimalDigits: 2, ).format(widget.subscription.eventSavings)} 절약' : '${NumberFormat.currency( locale: 'ko_KR', symbol: '₩', decimalDigits: 0, ).format(widget.subscription.eventSavings)} 절약', style: const TextStyle( fontSize: 12, fontWeight: FontWeight.w600, color: Color(0xFFFF6B6B), ), ), ], ), ), const SizedBox(width: 8), // 이벤트 종료일까지 남은 일수 if (widget.subscription.eventEndDate != null) ...[ Text( '${widget.subscription.eventEndDate!.difference(DateTime.now()).inDays}일 남음', style: TextStyle( fontSize: 11, color: AppColors.textSecondary, ), ), ], ], ), ], ], ), ), ], ), ), ], ), ), ), ), ); }, ), ), ); } }