import 'package:flutter/material.dart'; import 'package:fl_chart/fl_chart.dart'; import 'package:provider/provider.dart'; import '../../models/subscription_model.dart'; import '../../services/currency_util.dart'; import '../../services/exchange_rate_service.dart'; // import '../../theme/app_colors.dart'; import '../../theme/color_scheme_ext.dart'; // Glass 제거: Material 3 Card 사용 import '../themed_text.dart'; import 'analysis_badge.dart'; import '../../l10n/app_localizations.dart'; import '../../providers/locale_provider.dart'; import '../../utils/reduce_motion.dart'; /// 구독 서비스 비율을 파이 차트로 보여주는 카드 위젯 class SubscriptionPieChartCard extends StatefulWidget { final List subscriptions; final AnimationController animationController; const SubscriptionPieChartCard({ super.key, required this.subscriptions, required this.animationController, }); @override State createState() => _SubscriptionPieChartCardState(); } class _SubscriptionPieChartCardState extends State { int _touchedIndex = -1; // kept for compatibility previously; computation now happens per build String? _lastLocale; // 차트 팔레트: ColorScheme + 보조 상수(성공/경고/액센트) List _getChartColors(ColorScheme scheme) => [ scheme.primary, scheme.success, scheme.warning, scheme.error, scheme.tertiary, scheme.secondary, const Color(0xFFEC4899), // accent ]; @override void initState() { super.initState(); _initializeFuture(); } @override void didUpdateWidget(SubscriptionPieChartCard oldWidget) { super.didUpdateWidget(oldWidget); // subscriptions나 locale이 변경된 경우만 Future 재생성 final currentLocale = context.read().locale.languageCode; if (!_listEquals(oldWidget.subscriptions, widget.subscriptions) || _lastLocale != currentLocale) { _initializeFuture(); } } void _initializeFuture() { _lastLocale = context.read().locale.languageCode; // no-op: Future computed on demand in build } bool _listEquals(List a, List b) { if (a.length != b.length) return false; for (int i = 0; i < a.length; i++) { if (a[i].id != b[i].id || a[i].currentPrice != b[i].currentPrice || a[i].currency != b[i].currency || a[i].serviceName != b[i].serviceName) { return false; } } return true; } // 파이 차트 섹션 데이터 (언어별 기본 통화로 환산) Future> _getPieSections() async { if (widget.subscriptions.isEmpty) return []; // 현재 locale 가져오기 final locale = context.read().locale.languageCode; final defaultCurrency = CurrencyUtil.getDefaultCurrency(locale); // Chart palette (capture scheme before any awaits) final scheme = Theme.of(context).colorScheme; final chartColors = _getChartColors(scheme); // 개별 구독의 비율 계산을 위한 값들 (기본 통화로 환산) List sectionValues = []; // 각 구독의 현재 가격을 언어별 기본 통화로 환산 for (var subscription in widget.subscriptions) { double value = subscription.currentPrice; if (subscription.currency == defaultCurrency) { // 이미 기본 통화인 경우 그대로 사용 sectionValues.add(value); } else if (subscription.currency == 'USD') { // USD를 기본 통화로 변환 final converted = await ExchangeRateService() .convertUsdToTarget(value, defaultCurrency); sectionValues.add(converted ?? value); } else if (defaultCurrency == 'USD') { // 기본 통화가 USD인 경우 다른 통화를 USD로 변환 final converted = await ExchangeRateService() .convertTargetToUsd(value, subscription.currency); sectionValues.add(converted ?? value); } else { // 기타 통화는 일단 그대로 사용 (환율 정보가 없는 경우) sectionValues.add(value); } } // 총합 계산 double sectionsTotal = sectionValues.fold(0, (sum, value) => sum + value); // 총합이 0이면 빈 배열 반환 if (sectionsTotal == 0) return []; // 섹션 데이터 생성 (터치 상태 제외) final sections = List.generate(widget.subscriptions.length, (i) { final percentage = (sectionValues[i] / sectionsTotal) * 100; final index = i % chartColors.length; return PieChartSectionData( value: sectionValues[i], title: '${percentage.toStringAsFixed(1)}%', titleStyle: const TextStyle( fontSize: 12.0, fontWeight: FontWeight.bold, color: Colors.white, shadows: [ Shadow(color: Colors.black26, blurRadius: 2, offset: Offset(0, 1)) ], ), color: chartColors[index], radius: 100.0, titlePositionPercentageOffset: 0.6, badgeWidget: null, badgePositionPercentageOffset: .98, ); }); return sections; } // 배지 위젯 생성 Widget _createBadgeWidget(int index) { if (index >= widget.subscriptions.length) return const SizedBox.shrink(); final subscription = widget.subscriptions[index]; final chartColors = _getChartColors(Theme.of(context).colorScheme); final colorIndex = index % chartColors.length; return IgnorePointer( child: AnalysisBadge( size: 40, borderColor: chartColors[colorIndex], subscription: subscription, ), ); } // 터치 상태를 반영한 섹션 데이터 생성 List _applyTouchedState( List sections) { return List.generate(sections.length, (i) { final section = sections[i]; final isTouched = _touchedIndex == i; final fontSize = isTouched ? 16.0 : 12.0; final radius = isTouched ? 105.0 : 100.0; return PieChartSectionData( value: section.value, title: section.title, titleStyle: section.titleStyle?.copyWith(fontSize: fontSize) ?? TextStyle( fontSize: fontSize, fontWeight: FontWeight.bold, color: Colors.white, shadows: const [ Shadow( color: Colors.black26, blurRadius: 2, offset: Offset(0, 1)) ], ), color: section.color, radius: radius, titlePositionPercentageOffset: section.titlePositionPercentageOffset, badgeWidget: isTouched ? _createBadgeWidget(i) : null, badgePositionPercentageOffset: section.badgePositionPercentageOffset, ); }); } @override Widget build(BuildContext context) { return SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: FadeTransition( opacity: CurvedAnimation( parent: widget.animationController, curve: const Interval(0.0, 0.7, curve: Curves.easeOut), ), child: SlideTransition( position: Tween( begin: const Offset(0, 0.2), end: Offset.zero, ).animate(CurvedAnimation( parent: widget.animationController, curve: const Interval(0.0, 0.7, 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) .subscriptionServiceRatio, style: const TextStyle( fontSize: 18, ), ), FutureBuilder( future: CurrencyUtil.getExchangeRateInfoForLocale( context .watch() .locale .languageCode), builder: (context, snapshot) { if (snapshot.hasData && snapshot.data!.isNotEmpty) { return Container( padding: const EdgeInsets.symmetric( horizontal: 8, vertical: 4, ), decoration: BoxDecoration( color: Theme.of(context) .colorScheme .primary .withValues(alpha: 0.08), borderRadius: BorderRadius.circular(4), border: Border.all( color: Theme.of(context) .colorScheme .primary .withValues(alpha: 0.3), width: 1, ), ), child: Text( AppLocalizations.of(context) .exchangeRateFormat(snapshot.data!), style: TextStyle( fontSize: 12, fontWeight: FontWeight.w500, color: Theme.of(context).colorScheme.primary, ), ), ); } return const SizedBox.shrink(); }, ), ], ), const SizedBox(height: 8), ThemedText.subtitle( text: AppLocalizations.of(context).monthlyExpenseBasis, style: const TextStyle( fontSize: 14, ), ), const SizedBox(height: 16), Center( child: widget.subscriptions.isEmpty ? SizedBox( height: 250, child: Center( child: ThemedText( AppLocalizations.of(context) .noSubscriptionServices, style: const TextStyle( fontSize: 16, ), ), ), ) : SizedBox( height: 250, child: FutureBuilder>( future: _getPieSections(), builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.waiting) { return const Center( child: CircularProgressIndicator(), ); } if (!snapshot.hasData || snapshot.data!.isEmpty) { return Center( child: ThemedText( AppLocalizations.of(context) .noSubscriptionServices, style: const TextStyle( fontSize: 16, ), ), ); } return RepaintBoundary( child: PieChart( PieChartData( borderData: FlBorderData(show: false), sectionsSpace: 2, centerSpaceRadius: 60, sections: _applyTouchedState(snapshot.data!), pieTouchData: PieTouchData( enabled: true, touchCallback: (FlTouchEvent event, pieTouchResponse) { // 터치 응답이 없거나 섹션이 없는 경우 if (pieTouchResponse == null || pieTouchResponse .touchedSection == null) { // 차트 밖으로 나갔을 때만 리셋 if (_touchedIndex != -1) { setState(() { _touchedIndex = -1; }); } return; } final touchedIndex = pieTouchResponse.touchedSection! .touchedSectionIndex; // 탭 이벤트 처리 (토글) if (event is FlTapUpEvent) { setState(() { // 동일 섹션 탭하면 선택 해제, 아니면 선택 _touchedIndex = (_touchedIndex == touchedIndex) ? -1 : touchedIndex; }); return; } // hover 이벤트 처리 (단순 표시) if (event is FlPointerHoverEvent || event is FlPointerEnterEvent) { // 현재 인덱스와 다른 경우만 업데이트 if (_touchedIndex != touchedIndex) { setState(() { _touchedIndex = touchedIndex; }); } } }, ), ), duration: ReduceMotion.isEnabled(context) ? Duration.zero : const Duration(milliseconds: 300), curve: Curves.easeOut, ), ); }, ), ), ), const SizedBox(height: 16), // 서비스 목록 Column( children: widget.subscriptions.isEmpty ? [] : List.generate( widget.subscriptions.length, (index) { final subscription = widget.subscriptions[index]; final chartColors = _getChartColors( Theme.of(context).colorScheme); final color = chartColors[index % chartColors.length]; return Padding( padding: const EdgeInsets.only(bottom: 4.0), child: Row( children: [ Container( width: 12, height: 12, decoration: BoxDecoration( color: color, shape: BoxShape.circle, ), ), const SizedBox(width: 8), Expanded( child: ThemedText( subscription.serviceName, style: const TextStyle( fontSize: 14, ), overflow: TextOverflow.ellipsis, ), ), FutureBuilder( future: CurrencyUtil .formatSubscriptionAmountWithLocale( subscription, context .read() .locale .languageCode), builder: (context, snapshot) { if (snapshot.hasData) { return ThemedText( snapshot.data!, style: const TextStyle( fontSize: 14, fontWeight: FontWeight.bold, ), ); } return const SizedBox( width: 20, height: 20, child: CircularProgressIndicator( strokeWidth: 2, ), ); }, ), ], ), ); }, ), ), ], ), ), ), ), ), ), ); } }