import 'dart:io'; import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:carousel_slider/carousel_slider.dart'; import 'package:lucide_icons/lucide_icons.dart'; import '../../providers/sakenowa_providers.dart'; import '../../providers/sake_list_provider.dart'; import '../../providers/theme_provider.dart'; import '../../models/sakenowa/sakenowa_models.dart'; import '../../services/mbti_compatibility_service.dart'; import '../../theme/app_colors.dart'; /// さけのわパーソナライズドランキングセクション /// /// さけのわTOP100 × ユーザーデータの掛け合わせ /// - 既飲銘柄ハイライト /// - MBTI相性スコア表示 /// - 3Dカルーセル表示 class SakenowaRankingSection extends ConsumerStatefulWidget { final int displayCount; const SakenowaRankingSection({ super.key, this.displayCount = 10, }); @override ConsumerState createState() => _SakenowaRankingSectionState(); } class _SakenowaRankingSectionState extends ConsumerState { int _currentIndex = 0; final CarouselSliderController _carouselController = CarouselSliderController(); @override Widget build(BuildContext context) { final rankingsAsync = ref.watch(sakenowaRankingsProvider); final brandsAsync = ref.watch(sakenowaBrandsProvider); final flavorChartsAsync = ref.watch(sakenowaFlavorChartsProvider); final userItemsAsync = ref.watch(allSakeItemsProvider); final userProfile = ref.watch(userProfileProvider); final appColors = Theme.of(context).extension() ?? AppColors.washiLight(); final theme = Theme.of(context); // 既飲銘柄名のセット(AsyncValueから取得) final drunkNames = userItemsAsync.maybeWhen( data: (items) => items.map((i) => i.displayData.displayName.toLowerCase()).toSet(), orElse: () => {}, ); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // セクションヘッダー Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: Row( children: [ Icon( LucideIcons.trendingUp, size: 20, color: theme.colorScheme.primary, ), const SizedBox(width: 8), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'みんなの人気TOP${widget.displayCount}', style: theme.textTheme.titleMedium?.copyWith( fontWeight: FontWeight.bold, ), ), Text( 'さけのわTOP100からランキング順で表示', style: TextStyle( fontSize: 11, color: appColors.textSecondary, ), ), ], ), ), // さけのわ帰属表示 GestureDetector( onTap: () => _showAttribution(context), child: Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( color: appColors.surfaceSubtle, borderRadius: BorderRadius.circular(12), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon(LucideIcons.info, size: 12, color: appColors.iconSubtle), const SizedBox(width: 4), Text( 'さけのわ', style: TextStyle( fontSize: 10, color: appColors.textTertiary, ), ), ], ), ), ), ], ), ), // 3Dカルーセル rankingsAsync.when( data: (rankings) { return brandsAsync.when( data: (brands) { return flavorChartsAsync.when( data: (flavorCharts) { final brandMap = {for (var b in brands) b.id: b}; final chartMap = {for (var c in flavorCharts) c.brandId: c}; // 表示用データを作成 final displayItems = rankings.take(widget.displayCount).map((ranking) { final brand = brandMap[ranking.brandId]; if (brand == null) return null; final chart = chartMap[ranking.brandId]; final isDrunk = drunkNames.contains(brand.name.toLowerCase()); // ✅ 既飲の場合、ユーザーの撮影画像を取得 String? userImagePath; if (isDrunk) { final matchedSake = userItemsAsync.maybeWhen( data: (items) { try { return items.firstWhere( (i) => i.displayData.displayName.toLowerCase() == brand.name.toLowerCase(), ); } catch (e) { return null; } }, orElse: () => null, ); if (matchedSake != null && matchedSake.displayData.imagePaths.isNotEmpty) { userImagePath = matchedSake.displayData.imagePaths.first; } } return _RankingDisplayItem( rank: ranking.rank, brand: brand, flavorChart: chart, isDrunk: isDrunk, mbtiType: userProfile.mbti, userImagePath: userImagePath, // ✅ 追加 ); }).whereType<_RankingDisplayItem>().toList(); if (displayItems.isEmpty) { return _buildErrorState(context, appColors); } return _buildCarousel(context, displayItems, appColors); }, loading: () => _buildLoadingState(appColors), error: (err, stack) => _buildErrorState(context, appColors), ); }, loading: () => _buildLoadingState(appColors), error: (err, stack) => _buildErrorState(context, appColors), ); }, loading: () => _buildLoadingState(appColors), error: (err, stack) => _buildErrorState(context, appColors), ), ], ); } Widget _buildCarousel(BuildContext context, List<_RankingDisplayItem> items, AppColors appColors) { return Column( children: [ SizedBox( height: 280, child: CarouselSlider.builder( carouselController: _carouselController, itemCount: items.length, options: CarouselOptions( height: 280, enlargeCenterPage: true, enlargeFactor: 0.3, enlargeStrategy: CenterPageEnlargeStrategy.zoom, viewportFraction: 0.45, enableInfiniteScroll: items.length > 2, autoPlay: false, scrollPhysics: const BouncingScrollPhysics(), onPageChanged: (index, reason) { setState(() => _currentIndex = index); }, ), itemBuilder: (context, index, realIndex) { final item = items[index]; final distance = (_currentIndex - index).abs(); final depth = distance == 0 ? 1.0 : math.max(0.65, 1.0 - (distance * 0.18)); return _buildRankingCard(context, item, index == _currentIndex, depth, appColors); }, ), ), const SizedBox(height: 12), // インジケーター _buildIndicator(items.length, appColors), ], ); } Widget _buildIndicator(int count, AppColors appColors) { return Row( mainAxisAlignment: MainAxisAlignment.center, children: List.generate( count, (index) { final isActive = _currentIndex == index; return GestureDetector( onTap: () => _carouselController.animateToPage(index), child: AnimatedContainer( duration: const Duration(milliseconds: 300), curve: Curves.easeOutCubic, width: isActive ? 20 : 8, height: 8, margin: const EdgeInsets.symmetric(horizontal: 3), decoration: BoxDecoration( borderRadius: BorderRadius.circular(4), color: isActive ? appColors.brandPrimary : appColors.divider, boxShadow: isActive ? [BoxShadow(color: appColors.brandPrimary.withValues(alpha: 0.4), blurRadius: 4)] : null, ), ), ); }, ), ); } Widget _buildRankingCard( BuildContext context, _RankingDisplayItem item, bool isCurrent, double depth, AppColors appColors, ) { // MBTI相性計算(簡易版 - フレーバーチャートベース) final mbtiScore = item.mbtiType != null && item.flavorChart != null ? _calculateMbtiFlavorMatch(item.mbtiType!, item.flavorChart!) : null; return GestureDetector( onTap: () => _showBrandDetail(context, item, mbtiScore, appColors), child: AnimatedContainer( duration: const Duration(milliseconds: 400), curve: Curves.easeOutCubic, margin: EdgeInsets.symmetric( vertical: isCurrent ? 0 : 14 * (1 - depth + 0.4), horizontal: 6, ), transform: Matrix4.diagonal3Values(depth, depth, 1.0) ..setEntry(3, 2, 0.001), transformAlignment: Alignment.center, child: Card( elevation: isCurrent ? 16 : 4 * depth, shadowColor: Colors.black.withValues(alpha: 0.3), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(isCurrent ? 20 : 16), ), clipBehavior: Clip.antiAliasWithSaveLayer, child: Stack( fit: StackFit.expand, children: [ // ✅ 既飲画像があればそれを使用、なければグラデーション if (item.userImagePath != null) Image.file( File(item.userImagePath!), fit: BoxFit.cover, ) else Container( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [ appColors.brandPrimary.withValues(alpha: 0.15), appColors.surfaceSubtle, ], ), ), ), // ✅ グラデーションオーバーレイ(画像の上に重ねてテキスト可読性確保) if (item.userImagePath != null) Container( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, stops: const [0.0, 0.5, 1.0], colors: [ Colors.black.withValues(alpha: 0.3), Colors.black.withValues(alpha: 0.5), Colors.black.withValues(alpha: 0.8), ], ), ), ), // コンテンツ Padding( padding: const EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.center, children: [ // ランキングバッジ _buildRankBadge(item.rank, appColors), const SizedBox(height: 8), // 銘柄名 Expanded( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Text( item.brand.name, maxLines: 2, overflow: TextOverflow.ellipsis, textAlign: TextAlign.center, style: TextStyle( color: item.userImagePath != null ? Colors.white : appColors.textPrimary, // ✅ 画像ある場合は白文字 fontSize: isCurrent ? 14 : 12, fontWeight: FontWeight.bold, shadows: item.userImagePath != null ? [const Shadow(color: Colors.black, blurRadius: 6)] // ✅ 画像背景時は影付き : null, ), ), if (item.flavorChart != null) ...[ const SizedBox(height: 4), Text( _getTopFlavors(item.flavorChart!), style: TextStyle( color: item.userImagePath != null ? Colors.white70 : appColors.textSecondary, // ✅ 画像ある場合は白文字 fontSize: 10, ), ), ], ], ), ), // MBTI相性(あれば) if (mbtiScore != null) ...[ Container( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3), decoration: BoxDecoration( color: _getMbtiColor(mbtiScore, appColors).withValues(alpha: 0.15), borderRadius: BorderRadius.circular(8), ), child: Text( '${_getMbtiStars(mbtiScore)} ${item.mbtiType}', style: TextStyle( color: _getMbtiColor(mbtiScore, appColors), fontSize: 9, ), ), ), ], ], ), ), // 既飲バッジ if (item.isDrunk) Positioned( top: 8, right: 8, child: Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( color: appColors.brandPrimary, borderRadius: BorderRadius.circular(10), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ const Icon(Icons.check, color: Colors.white, size: 12), const SizedBox(width: 2), const Text( '飲んだ', style: TextStyle( color: Colors.white, fontSize: 10, fontWeight: FontWeight.bold, ), ), ], ), ), ), // 中央カードのグロー効果 if (isCurrent) Positioned( top: 0, left: 0, right: 0, height: 3, child: Container( decoration: BoxDecoration( gradient: LinearGradient( colors: [ Colors.transparent, appColors.brandPrimary.withValues(alpha: 0.8), Colors.transparent, ], ), ), ), ), ], ), ), ), ); } Widget _buildRankBadge(int rank, AppColors appColors) { Color badgeColor; switch (rank) { case 1: badgeColor = const Color(0xFFD4A574); // ゴールド break; case 2: badgeColor = const Color(0xFF9E9A94); // シルバー break; case 3: badgeColor = const Color(0xFFB8860B); // ブロンズ break; default: badgeColor = appColors.textSecondary; } return Container( width: 36, height: 36, decoration: BoxDecoration( color: badgeColor, shape: BoxShape.circle, boxShadow: [ BoxShadow( color: badgeColor.withValues(alpha: 0.4), blurRadius: 8, offset: const Offset(0, 2), ), ], ), alignment: Alignment.center, child: Text( '$rank', style: const TextStyle( color: Colors.white, fontWeight: FontWeight.bold, fontSize: 16, ), ), ); } String _getTopFlavors(SakenowaFlavorChart chart) { final flavors = [ ('華やか', chart.f1), ('芳醇', chart.f2), ('重厚', chart.f3), ('穏やか', chart.f4), ('軽快', chart.f5), ('ドライ', chart.f6), ]; flavors.sort((a, b) => b.$2.compareTo(a.$2)); return flavors.take(2).map((f) => f.$1).join('・'); } // MBTI × フレーバーチャートのマッチ度(全6軸活用版) double _calculateMbtiFlavorMatch(String mbtiType, SakenowaFlavorChart chart) { // MBTICompatibilityServiceの五味好みを参照 final flavorPrefs = MBTICompatibilityService.flavorPreferences[mbtiType]; if (flavorPrefs == null) return 0.5; // ユーザーの五味好み(1-5スケール)を0-1に正規化 final userAroma = ((flavorPrefs['aroma'] ?? 3) - 1) / 4; final userSweetness = ((flavorPrefs['sweetness'] ?? 3) - 1) / 4; final userAcidity = ((flavorPrefs['acidity'] ?? 3) - 1) / 4; final userBitterness = ((flavorPrefs['bitterness'] ?? 3) - 1) / 4; final userBody = ((flavorPrefs['body'] ?? 3) - 1) / 4; // さけのわフレーバー6軸との詳細マッピング // f1: 華やか → aroma // f2: 芳醇 → sweetness + aroma(芳醇は甘みと香りの複合) // f3: 重厚 → body // f4: 穏やか → sweetness(低acidity/bitterness) // f5: 軽快 → acidity(低body) // f6: ドライ → bitterness(低sweetness) double diff = 0; diff += (userAroma - chart.f1).abs(); // 華やか → 香り diff += (userSweetness - (chart.f2 + chart.f4) / 2).abs(); // 芳醇・穏やか → 甘み diff += (userBody - chart.f3).abs(); // 重厚 → コク diff += (userAcidity - chart.f5).abs(); // 軽快 → 酸味 diff += (userBitterness - chart.f6).abs(); // ドライ → キレ // 5軸の差分合計を0-1スコアに変換 return (1.0 - (diff / 5.0)).clamp(0.3, 1.0); } String _getMbtiStars(double score) { final starCount = (score * 5).round().clamp(1, 5); return '★' * starCount + '☆' * (5 - starCount); } Color _getMbtiColor(double score, AppColors appColors) { if (score >= 0.75) return appColors.brandPrimary; if (score >= 0.55) return appColors.textSecondary; return appColors.textTertiary; } Widget _buildLoadingState(AppColors appColors) { return SizedBox( height: 280, child: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ CircularProgressIndicator(color: appColors.brandPrimary), const SizedBox(height: 12), Text( 'ランキングを取得中...', style: TextStyle(color: appColors.textSecondary, fontSize: 12), ), ], ), ), ); } Widget _buildErrorState(BuildContext context, AppColors appColors) { return SizedBox( height: 200, child: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon(LucideIcons.wifiOff, color: appColors.iconSubtle, size: 32), const SizedBox(height: 8), Text( 'ランキングを取得できませんでした', style: TextStyle(color: appColors.textSecondary, fontSize: 12), ), ], ), ), ); } void _showAttribution(BuildContext context) { final appColors = Theme.of(context).extension() ?? AppColors.washiLight(); showDialog( context: context, builder: (context) => AlertDialog( backgroundColor: appColors.surfaceSubtle, title: Row( children: [ Icon(LucideIcons.externalLink, color: appColors.brandPrimary, size: 20), const SizedBox(width: 8), const Text('さけのわデータ'), ], ), content: const Text( 'このランキングは「さけのわ」のデータを利用しています。\n\n' 'https://sakenowa.com\n\n' '※ユーザーの投稿に基づくデータです。', ), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(), child: Text('閉じる', style: TextStyle(color: appColors.brandPrimary)), ), ], ), ); } void _showBrandDetail( BuildContext context, _RankingDisplayItem item, double? mbtiScore, AppColors appColors, ) { showModalBottomSheet( context: context, backgroundColor: appColors.surfaceSubtle, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(20)), ), builder: (context) => Padding( padding: const EdgeInsets.all(24), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ // ヘッダー Row( children: [ _buildRankBadge(item.rank, appColors), const SizedBox(width: 12), Expanded( child: Text( item.brand.name, style: Theme.of(context).textTheme.headlineSmall?.copyWith( fontWeight: FontWeight.bold, ), ), ), if (item.isDrunk) Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), decoration: BoxDecoration( color: appColors.brandPrimary, borderRadius: BorderRadius.circular(16), ), child: const Row( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.check, color: Colors.white, size: 14), SizedBox(width: 4), Text( '飲んだ', style: TextStyle( color: Colors.white, fontSize: 12, fontWeight: FontWeight.bold, ), ), ], ), ), ], ), const SizedBox(height: 20), // MBTI相性 if (mbtiScore != null && item.mbtiType != null) ...[ Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: _getMbtiColor(mbtiScore, appColors).withValues(alpha: 0.1), borderRadius: BorderRadius.circular(12), ), child: Row( children: [ Icon(LucideIcons.brainCircuit, color: appColors.brandPrimary), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( '${item.mbtiType}との相性', style: TextStyle( color: appColors.textSecondary, fontSize: 12, ), ), const SizedBox(height: 4), Text( _getMbtiStars(mbtiScore), style: TextStyle( color: _getMbtiColor(mbtiScore, appColors), fontSize: 20, letterSpacing: 2, ), ), ], ), ), Text( '${(mbtiScore * 100).round()}%', style: TextStyle( color: _getMbtiColor(mbtiScore, appColors), fontSize: 24, fontWeight: FontWeight.bold, ), ), ], ), ), const SizedBox(height: 16), ], // フレーバーチャート if (item.flavorChart != null) ...[ Text( 'フレーバー特徴', style: TextStyle( fontWeight: FontWeight.bold, color: appColors.textPrimary, ), ), const SizedBox(height: 12), _buildFlavorBars(item.flavorChart!, appColors), ], const SizedBox(height: 16), // 帰属表示 Text( 'データ提供: さけのわ', style: TextStyle( fontSize: 11, color: appColors.textTertiary, ), ), ], ), ), ); } Widget _buildFlavorBars(SakenowaFlavorChart chart, AppColors appColors) { final flavors = [ ('華やか', chart.f1), ('芳醇', chart.f2), ('重厚', chart.f3), ('穏やか', chart.f4), ('軽快', chart.f5), ('ドライ', chart.f6), ]; return Column( children: flavors.map((f) => Padding( padding: const EdgeInsets.symmetric(vertical: 4), child: Row( children: [ SizedBox( width: 50, child: Text(f.$1, style: TextStyle(fontSize: 12, color: appColors.textSecondary)), ), Expanded( child: ClipRRect( borderRadius: BorderRadius.circular(4), child: LinearProgressIndicator( value: f.$2, backgroundColor: appColors.divider, color: appColors.brandPrimary, minHeight: 8, ), ), ), const SizedBox(width: 8), Text( '${(f.$2 * 100).toInt()}%', style: TextStyle(fontSize: 11, color: appColors.textSecondary), ), ], ), )).toList(), ); } } /// 表示用データクラス class _RankingDisplayItem { final int rank; final SakenowaBrand brand; final SakenowaFlavorChart? flavorChart; final bool isDrunk; final String? mbtiType; final String? userImagePath; // ✅ 追加: ユーザーの撮影画像 _RankingDisplayItem({ required this.rank, required this.brand, this.flavorChart, required this.isDrunk, this.mbtiType, this.userImagePath, // ✅ 追加 }); }