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

455 lines
14 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 SakenowaDetailRecommendationSection extends ConsumerWidget {
final String currentSakeName;
final Map<String, double>? 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<AppColors>()!;
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<SakenowaBrand> brands,
List<SakenowaFlavorChart> 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<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);
}
}
class _RecommendationItem {
final SakenowaBrand brand;
final SakenowaFlavorChart chart;
final double similarity;
_RecommendationItem({
required this.brand,
required this.chart,
required this.similarity,
});
}