ponshu-room-lite/lib/widgets/sakenowa/sakenowa_new_recommendation...

692 lines
22 KiB
Dart

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<AppColors>()!;
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<SakenowaRanking> rankings,
List<SakenowaBrand> brands,
List<SakenowaFlavorChart> 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<String, double> _calculateUserAverageTaste(List userItems) {
final tasteSums = <String, double>{
'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<String, double> taste1,
Map<String, double> 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<String, double> userTaste,
Map<String, double> targetTaste,
double similarity,
) {
// Find the two most similar axes
final similarities = <String, double>{};
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();
}