import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:lucide_icons/lucide_icons.dart'; import '../../theme/app_colors.dart'; import '../../providers/sakenowa_providers.dart'; import '../../providers/sake_list_provider.dart'; import '../../models/sakenowa/sakenowa_models.dart'; import 'dart:math' as math; /// カード詳細画面用:さけのわ連携おすすめセクション /// /// 現在表示中の日本酒のフレーバーに近い、まだ飲んでいない銘柄を推薦 class SakenowaDetailRecommendationSection extends ConsumerWidget { final String currentSakeName; final Map? currentTasteData; final int displayCount; const SakenowaDetailRecommendationSection({ super.key, required this.currentSakeName, this.currentTasteData, this.displayCount = 3, }); @override Widget build(BuildContext context, WidgetRef ref) { final appColors = Theme.of(context).extension()!; final userItemsAsync = ref.watch(allSakeItemsProvider); final brandsAsync = ref.watch(sakenowaBrandsProvider); final chartsAsync = ref.watch(sakenowaFlavorChartsProvider); // 五味データがない場合は表示しない if (currentTasteData == null || currentTasteData!.isEmpty) { return const SizedBox.shrink(); } return brandsAsync.when( data: (brands) => chartsAsync.when( data: (charts) => userItemsAsync.when( data: (userItems) => _buildRecommendations( context, appColors, brands, charts, userItems, ), loading: () => _buildLoading(), error: (err, stack) => const SizedBox.shrink(), ), loading: () => _buildLoading(), error: (err, stack) => const SizedBox.shrink(), ), loading: () => _buildLoading(), error: (err, stack) => const SizedBox.shrink(), ); } Widget _buildLoading() { return const Padding( padding: EdgeInsets.all(16), child: Center(child: CircularProgressIndicator(strokeWidth: 2)), ); } Widget _buildRecommendations( BuildContext context, AppColors appColors, List brands, List charts, List userItems, ) { // ユーザーが持っている銘柄名(小文字) final ownedNames = userItems .map((i) => i.displayData.displayName.toLowerCase()) .toSet(); // 現在の銘柄も除外 ownedNames.add(currentSakeName.toLowerCase()); // チャートマップ final chartMap = {for (var c in charts) c.brandId: c}; // 類似度計算してソート final recommendations = <_RecommendationItem>[]; for (final brand in brands) { // 持っている銘柄は除外 if (ownedNames.contains(brand.name.toLowerCase())) continue; final chart = chartMap[brand.id]; if (chart == null) continue; // 類似度計算 final targetTaste = chart.toFiveAxisTaste(); final similarity = _calculateCosineSimilarity(currentTasteData!, targetTaste); recommendations.add(_RecommendationItem( brand: brand, chart: chart, similarity: similarity, )); } // 類似度でソート recommendations.sort((a, b) => b.similarity.compareTo(a.similarity)); final topItems = recommendations.take(displayCount).toList(); if (topItems.isEmpty) { return const SizedBox.shrink(); } return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Divider(), const SizedBox(height: 16), Row( children: [ Icon(LucideIcons.compass, size: 16, color: appColors.brandAccent), const SizedBox(width: 8), Text( 'さけのわで見つけた類似銘柄', style: Theme.of(context).textTheme.titleMedium?.copyWith( fontWeight: FontWeight.bold, ), ), ], ), const SizedBox(height: 4), Text( 'この日本酒と似たフレーバーの未飲銘柄', style: Theme.of(context).textTheme.bodySmall?.copyWith( color: appColors.textSecondary, ), ), const SizedBox(height: 12), ...topItems.map((item) => _buildCard(context, appColors, item)), ], ); } Widget _buildCard(BuildContext context, AppColors appColors, _RecommendationItem item) { final percent = (item.similarity * 100).round(); return InkWell( onTap: () => _showDetailDialog(context, appColors, item), borderRadius: BorderRadius.circular(12), child: Container( margin: const EdgeInsets.only(bottom: 8), padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: appColors.surfaceSubtle, borderRadius: BorderRadius.circular(12), border: Border.all( color: appColors.divider.withValues(alpha: 0.3), ), ), child: Row( children: [ // 類似度バッジ Container( width: 44, height: 44, decoration: BoxDecoration( shape: BoxShape.circle, gradient: LinearGradient( colors: [ appColors.brandAccent.withValues(alpha: 0.2), appColors.brandPrimary.withValues(alpha: 0.1), ], ), ), child: Center( child: Text( '$percent%', style: TextStyle( fontSize: 11, fontWeight: FontWeight.bold, color: appColors.brandAccent, ), ), ), ), const SizedBox(width: 12), // 銘柄情報 Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( item.brand.name, style: TextStyle( fontWeight: FontWeight.bold, color: appColors.textPrimary, ), maxLines: 1, overflow: TextOverflow.ellipsis, ), const SizedBox(height: 2), Text( _getFlavorDescription(item.chart), style: TextStyle( fontSize: 11, color: appColors.textSecondary, ), ), ], ), ), Icon( LucideIcons.chevronRight, size: 16, color: appColors.iconSubtle, ), ], ), ), ); } void _showDetailDialog(BuildContext context, AppColors appColors, _RecommendationItem item) { final percent = (item.similarity * 100).round(); showDialog( context: context, builder: (context) => Dialog( shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), child: Padding( padding: const EdgeInsets.all(24), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ // Header Row( children: [ Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( color: appColors.brandAccent.withValues(alpha: 0.1), shape: BoxShape.circle, ), child: Icon( LucideIcons.compass, color: appColors.brandAccent, size: 24, ), ), const SizedBox(width: 12), Expanded( child: Text( item.brand.name, style: TextStyle( fontSize: 20, fontWeight: FontWeight.bold, color: appColors.textPrimary, ), ), ), IconButton( icon: const Icon(LucideIcons.x), onPressed: () => Navigator.of(context).pop(), ), ], ), const SizedBox(height: 24), // Similarity score Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( gradient: LinearGradient( colors: [ appColors.brandAccent.withValues(alpha: 0.1), appColors.brandPrimary.withValues(alpha: 0.1), ], ), borderRadius: BorderRadius.circular(12), ), child: Row( children: [ Icon(LucideIcons.heart, color: appColors.brandAccent), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( '$percent% 類似', style: TextStyle( fontSize: 18, fontWeight: FontWeight.bold, color: appColors.brandAccent, ), ), Text( 'この日本酒とフレーバーが似ています', style: TextStyle( fontSize: 13, color: appColors.textSecondary, ), ), ], ), ), ], ), ), const SizedBox(height: 24), // Flavor chart Text( 'フレーバーチャート', style: TextStyle( fontSize: 14, fontWeight: FontWeight.bold, color: appColors.textPrimary, ), ), const SizedBox(height: 12), _buildFlavorChartDisplay(appColors, item.chart), ], ), ), ), ); } Widget _buildFlavorChartDisplay(AppColors appColors, SakenowaFlavorChart chart) { final flavorData = [ ('華やか', chart.f1), ('芳醇', chart.f2), ('重厚', chart.f3), ('穏やか', chart.f4), ('軽快', chart.f5), ('ドライ', chart.f6), ]; return Column( children: flavorData.map((data) { final label = data.$1; final value = data.$2; return Padding( padding: const EdgeInsets.only(bottom: 6), child: Row( children: [ SizedBox( width: 50, child: Text( label, style: TextStyle( fontSize: 11, color: appColors.textSecondary, ), ), ), Expanded( child: Stack( children: [ Container( height: 6, decoration: BoxDecoration( color: appColors.surfaceSubtle, borderRadius: BorderRadius.circular(3), ), ), FractionallySizedBox( widthFactor: value, child: Container( height: 6, decoration: BoxDecoration( gradient: LinearGradient( colors: [ appColors.brandAccent, appColors.brandPrimary, ], ), borderRadius: BorderRadius.circular(3), ), ), ), ], ), ), const SizedBox(width: 6), Text( '${(value * 100).round()}%', style: TextStyle( fontSize: 10, color: appColors.textSecondary, ), ), ], ), ); }).toList(), ); } String _getFlavorDescription(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('・'); } double _calculateCosineSimilarity( Map taste1, Map taste2, ) { final keys = taste1.keys.toSet().intersection(taste2.keys.toSet()); if (keys.isEmpty) return 0.0; double dotProduct = 0.0; double magnitude1 = 0.0; double magnitude2 = 0.0; for (final key in keys) { final v1 = taste1[key] ?? 0.0; final v2 = taste2[key] ?? 0.0; dotProduct += v1 * v2; magnitude1 += v1 * v1; magnitude2 += v2 * v2; } final magnitude = math.sqrt(magnitude1) * math.sqrt(magnitude2); if (magnitude == 0) return 0.0; return (dotProduct / magnitude).clamp(0.0, 1.0); } } class _RecommendationItem { final SakenowaBrand brand; final SakenowaFlavorChart chart; final double similarity; _RecommendationItem({ required this.brand, required this.chart, required this.similarity, }); }