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 SakenowaNewRecommendationSection extends ConsumerWidget { final int displayCount; const SakenowaNewRecommendationSection({ super.key, this.displayCount = 5, }); @override Widget build(BuildContext context, WidgetRef ref) { final appColors = Theme.of(context).extension()!; final userItemsAsync = ref.watch(allSakeItemsProvider); final rankingsAsync = ref.watch(sakenowaRankingsProvider); final brandsAsync = ref.watch(sakenowaBrandsProvider); final chartsAsync = ref.watch(sakenowaFlavorChartsProvider); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // Section Header Row( children: [ Icon(LucideIcons.compass, color: appColors.brandPrimary, size: 24), const SizedBox(width: 8), Text( 'あなたへの新しいおすすめ', style: Theme.of(context).textTheme.titleLarge?.copyWith( fontWeight: FontWeight.bold, color: appColors.brandPrimary, ), ), ], ), const SizedBox(height: 4), Text( 'さけのわTOP100から未飲銘柄をセレクト', style: Theme.of(context).textTheme.bodySmall?.copyWith( color: appColors.textSecondary, ), ), const SizedBox(height: 16), // Content userItemsAsync.when( data: (userItems) { if (userItems.isEmpty) { return _buildEmptyState(context, appColors); } return rankingsAsync.when( data: (rankings) => brandsAsync.when( data: (brands) => chartsAsync.when( data: (charts) => _buildRecommendations( context, appColors, userItems, rankings, brands, charts, ), loading: () => _buildLoadingState(context), error: (e, s) => _buildErrorState(context, appColors, e), ), loading: () => _buildLoadingState(context), error: (e, s) => _buildErrorState(context, appColors, e), ), loading: () => _buildLoadingState(context), error: (e, s) => _buildErrorState(context, appColors, e), ); }, loading: () => _buildLoadingState(context), error: (e, s) => _buildErrorState(context, appColors, e), ), ], ); } Widget _buildEmptyState(BuildContext context, AppColors appColors) { return Container( padding: const EdgeInsets.all(32), decoration: BoxDecoration( color: appColors.surfaceSubtle, borderRadius: BorderRadius.circular(16), border: Border.all(color: appColors.divider.withValues(alpha: 0.3)), ), child: Column( children: [ Icon(LucideIcons.packageOpen, size: 48, color: appColors.textSecondary.withValues(alpha: 0.5)), const SizedBox(height: 16), Text( '日本酒を登録すると\nおすすめが表示されます', textAlign: TextAlign.center, style: TextStyle( fontSize: 14, color: appColors.textSecondary, height: 1.6, ), ), ], ), ); } Widget _buildLoadingState(BuildContext context) { return const Center( child: Padding( padding: EdgeInsets.all(32.0), child: CircularProgressIndicator(), ), ); } Widget _buildErrorState(BuildContext context, AppColors appColors, Object error) { return Container( padding: const EdgeInsets.all(24), decoration: BoxDecoration( color: appColors.error.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(16), ), child: Row( children: [ Icon(LucideIcons.alertCircle, color: appColors.error, size: 20), const SizedBox(width: 12), Expanded( child: Text( 'データの読み込みに失敗しました', style: TextStyle(color: appColors.error, fontSize: 13), ), ), ], ), ); } Widget _buildRecommendations( BuildContext context, AppColors appColors, List userItems, List rankings, List brands, List charts, ) { // Calculate user's overall taste profile (average of all items) final userAverageTaste = _calculateUserAverageTaste(userItems); // Map data for quick lookup final brandMap = {for (var b in brands) b.id: b}; final chartMap = {for (var c in charts) c.brandId: c}; // Get owned sake names (lowercase) final ownedNames = userItems.map((i) => i.displayData.displayName.toLowerCase()).toSet(); // Calculate similarity scores for all unowned sake in TOP100 final recommendations = <_NewRecommendationItem>[]; for (final ranking in rankings.take(100)) { final brand = brandMap[ranking.brandId]; final chart = chartMap[ranking.brandId]; if (brand == null || chart == null) continue; // Skip owned sake if (ownedNames.contains(brand.name.toLowerCase())) continue; // Calculate similarity with user's average taste final targetTaste = chart.toFiveAxisTaste(); final similarity = _calculateCosineSimilarity(userAverageTaste, targetTaste); recommendations.add(_NewRecommendationItem( ranking: ranking, brand: brand, flavorChart: chart, similarityScore: similarity, reason: _generateReason(userAverageTaste, targetTaste, similarity), )); } // Sort by similarity score (descending) recommendations.sort((a, b) => b.similarityScore.compareTo(a.similarityScore)); // Take top N items final topRecommendations = recommendations.take(displayCount).toList(); if (topRecommendations.isEmpty) { return Container( padding: const EdgeInsets.all(32), decoration: BoxDecoration( color: appColors.surfaceSubtle, borderRadius: BorderRadius.circular(16), border: Border.all(color: appColors.divider.withValues(alpha: 0.3)), ), child: Column( children: [ Icon(LucideIcons.checkCircle2, size: 48, color: appColors.brandAccent.withValues(alpha: 0.5)), const SizedBox(height: 16), Text( 'TOP100の銘柄は\nすべて登録済みです!', textAlign: TextAlign.center, style: TextStyle( fontSize: 14, color: appColors.textSecondary, height: 1.6, ), ), ], ), ); } // Display recommendations return Column( children: topRecommendations.asMap().entries.map((entry) { final index = entry.key; final rec = entry.value; return Padding( padding: EdgeInsets.only(bottom: index < topRecommendations.length - 1 ? 12 : 0), child: _buildRecommendationCard(context, appColors, rec, index + 1), ); }).toList(), ); } Widget _buildRecommendationCard( BuildContext context, AppColors appColors, _NewRecommendationItem item, int rank, ) { return InkWell( onTap: () => _showRecommendationDialog(context, appColors, item), borderRadius: BorderRadius.circular(16), child: Container( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [ appColors.surfaceSubtle, appColors.surfaceElevated, ], ), borderRadius: BorderRadius.circular(16), border: Border.all( color: appColors.brandAccent.withValues(alpha: 0.2), width: 1.5, ), boxShadow: [ BoxShadow( color: appColors.divider.withValues(alpha: 0.1), blurRadius: 8, offset: const Offset(0, 4), ), ], ), child: Padding( padding: const EdgeInsets.all(16), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ // Rank badge Container( width: 36, height: 36, decoration: BoxDecoration( gradient: LinearGradient( colors: [ appColors.brandPrimary, appColors.brandAccent, ], ), shape: BoxShape.circle, boxShadow: [ BoxShadow( color: appColors.brandPrimary.withValues(alpha: 0.3), blurRadius: 4, offset: const Offset(0, 2), ), ], ), child: Center( child: Text( '$rank', style: const TextStyle( color: Colors.white, fontWeight: FontWeight.bold, fontSize: 16, ), ), ), ), const SizedBox(width: 16), // Content Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // Brand name Text( item.brand.name, style: TextStyle( fontSize: 16, fontWeight: FontWeight.bold, color: appColors.textPrimary, ), ), const SizedBox(height: 4), // Ranking position Row( children: [ Icon(LucideIcons.trendingUp, size: 12, color: appColors.textSecondary), const SizedBox(width: 4), Text( 'さけのわ TOP${item.ranking.rank}', style: TextStyle( fontSize: 11, color: appColors.textSecondary, ), ), ], ), const SizedBox(height: 12), // Similarity score and reason Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), decoration: BoxDecoration( color: appColors.brandAccent.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(8), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon( LucideIcons.sparkles, size: 14, color: appColors.brandAccent, ), const SizedBox(width: 6), Text( '${item.similarityPercent}% ${item.reason}', style: TextStyle( fontSize: 12, fontWeight: FontWeight.w600, color: appColors.brandAccent, ), ), ], ), ), ], ), ), // Arrow icon Icon( LucideIcons.chevronRight, color: appColors.textSecondary.withValues(alpha: 0.4), size: 20, ), ], ), ), ), ); } /// Show recommendation detail dialog void _showRecommendationDialog( BuildContext context, AppColors appColors, _NewRecommendationItem item, ) { 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.brandPrimary.withValues(alpha: 0.1), shape: BoxShape.circle, ), child: Icon( LucideIcons.sparkles, color: appColors.brandPrimary, size: 24, ), ), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( item.brand.name, style: TextStyle( fontSize: 20, fontWeight: FontWeight.bold, color: appColors.textPrimary, ), ), Text( 'さけのわ TOP${item.ranking.rank}', style: TextStyle( fontSize: 12, color: appColors.textSecondary, ), ), ], ), ), 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.brandPrimary.withValues(alpha: 0.1), appColors.brandAccent.withValues(alpha: 0.1), ], ), borderRadius: BorderRadius.circular(12), ), child: Row( children: [ Icon(LucideIcons.heart, color: appColors.brandPrimary), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( '${item.similarityPercent}% マッチ', style: TextStyle( fontSize: 18, fontWeight: FontWeight.bold, color: appColors.brandPrimary, ), ), Text( item.reason, style: TextStyle( fontSize: 13, color: appColors.textSecondary, ), ), ], ), ), ], ), ), const SizedBox(height: 24), // Flavor chart radar display Text( 'フレーバーチャート', style: TextStyle( fontSize: 14, fontWeight: FontWeight.bold, color: appColors.textPrimary, ), ), const SizedBox(height: 12), _buildFlavorChartDisplay(appColors, item.flavorChart), ], ), ), ), ); } /// Build flavor chart display 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: 8), child: Row( children: [ SizedBox( width: 60, child: Text( label, style: TextStyle( fontSize: 12, color: appColors.textSecondary, ), ), ), Expanded( child: Stack( children: [ Container( height: 8, decoration: BoxDecoration( color: appColors.surfaceSubtle, borderRadius: BorderRadius.circular(4), ), ), FractionallySizedBox( widthFactor: value, child: Container( height: 8, decoration: BoxDecoration( gradient: LinearGradient( colors: [ appColors.brandPrimary, appColors.brandAccent, ], ), borderRadius: BorderRadius.circular(4), ), ), ), ], ), ), const SizedBox(width: 8), Text( '${(value * 100).round()}%', style: TextStyle( fontSize: 11, color: appColors.textSecondary, fontWeight: FontWeight.w600, ), ), ], ), ); }).toList(), ); } /// Calculate user's average taste profile from all items Map _calculateUserAverageTaste(List userItems) { final tasteSums = { 'aroma': 0.0, 'sweetness': 0.0, 'acidity': 0.0, 'bitterness': 0.0, 'body': 0.0, }; int validCount = 0; for (final item in userItems) { final tasteData = item.hiddenSpecs.activeTasteData; if (tasteData.isNotEmpty) { for (final key in tasteSums.keys) { tasteSums[key] = (tasteSums[key] ?? 0.0) + (tasteData[key] ?? 0.0); } validCount++; } } if (validCount == 0) { // Return neutral profile if no data return tasteSums.map((k, v) => MapEntry(k, 0.5)); } // Calculate averages return tasteSums.map((k, v) => MapEntry(k, v / validCount)); } /// Calculate cosine similarity between two taste vectors 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); } /// Generate recommendation reason based on taste similarity String _generateReason( Map userTaste, Map targetTaste, double similarity, ) { // Find the two most similar axes final similarities = {}; for (final key in userTaste.keys) { final diff = (userTaste[key]! - (targetTaste[key] ?? 0.5)).abs(); similarities[key] = 1.0 - diff; } final sortedAxes = similarities.entries.toList() ..sort((a, b) => b.value.compareTo(a.value)); // Japanese labels const axisLabels = { 'aroma': '香り', 'sweetness': '甘み', 'acidity': '酸味', 'bitterness': 'キレ', 'body': 'コク', }; if (sortedAxes.isNotEmpty) { final topAxis = sortedAxes.first; final label = axisLabels[topAxis.key] ?? topAxis.key; return '好みの$label'; } return 'おすすめ'; } } /// Internal recommendation item data class _NewRecommendationItem { final SakenowaRanking ranking; final SakenowaBrand brand; final SakenowaFlavorChart flavorChart; final double similarityScore; final String reason; _NewRecommendationItem({ required this.ranking, required this.brand, required this.flavorChart, required this.similarityScore, required this.reason, }); int get similarityPercent => (similarityScore * 100).round(); }