692 lines
22 KiB
Dart
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();
|
|
}
|