455 lines
14 KiB
Dart
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,
|
|
});
|
|
}
|