ponshu-room-lite/lib/widgets/sakenowa/sakenowa_ranking_section.dart

799 lines
28 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'dart:io';
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:carousel_slider/carousel_slider.dart';
import 'package:lucide_icons/lucide_icons.dart';
import '../../providers/sakenowa_providers.dart';
import '../../providers/sake_list_provider.dart';
import '../../providers/theme_provider.dart';
import '../../models/sakenowa/sakenowa_models.dart';
import '../../services/mbti_compatibility_service.dart';
import '../../theme/app_colors.dart';
/// さけのわパーソナライズドランキングセクション
///
/// さけのわTOP100 × ユーザーデータの掛け合わせ
/// - 既飲銘柄ハイライト
/// - MBTI相性スコア表示
/// - 3Dカルーセル表示
class SakenowaRankingSection extends ConsumerStatefulWidget {
final int displayCount;
const SakenowaRankingSection({
super.key,
this.displayCount = 10,
});
@override
ConsumerState<SakenowaRankingSection> createState() => _SakenowaRankingSectionState();
}
class _SakenowaRankingSectionState extends ConsumerState<SakenowaRankingSection> {
int _currentIndex = 0;
final CarouselSliderController _carouselController = CarouselSliderController();
@override
Widget build(BuildContext context) {
final rankingsAsync = ref.watch(sakenowaRankingsProvider);
final brandsAsync = ref.watch(sakenowaBrandsProvider);
final flavorChartsAsync = ref.watch(sakenowaFlavorChartsProvider);
final userItemsAsync = ref.watch(allSakeItemsProvider);
final userProfile = ref.watch(userProfileProvider);
final appColors = Theme.of(context).extension<AppColors>() ?? AppColors.washiLight();
final theme = Theme.of(context);
// 既飲銘柄名のセットAsyncValueから取得
final drunkNames = userItemsAsync.maybeWhen(
data: (items) => items.map((i) => i.displayData.displayName.toLowerCase()).toSet(),
orElse: () => <String>{},
);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// セクションヘッダー
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: [
Icon(
LucideIcons.trendingUp,
size: 20,
color: theme.colorScheme.primary,
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'みんなの人気TOP${widget.displayCount}',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
Text(
'さけのわTOP100からランキング順で表示',
style: TextStyle(
fontSize: 11,
color: appColors.textSecondary,
),
),
],
),
),
// さけのわ帰属表示
GestureDetector(
onTap: () => _showAttribution(context),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: appColors.surfaceSubtle,
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(LucideIcons.info, size: 12, color: appColors.iconSubtle),
const SizedBox(width: 4),
Text(
'さけのわ',
style: TextStyle(
fontSize: 10,
color: appColors.textTertiary,
),
),
],
),
),
),
],
),
),
// 3Dカルーセル
rankingsAsync.when(
data: (rankings) {
return brandsAsync.when(
data: (brands) {
return flavorChartsAsync.when(
data: (flavorCharts) {
final brandMap = {for (var b in brands) b.id: b};
final chartMap = {for (var c in flavorCharts) c.brandId: c};
// 表示用データを作成
final displayItems = rankings.take(widget.displayCount).map((ranking) {
final brand = brandMap[ranking.brandId];
if (brand == null) return null;
final chart = chartMap[ranking.brandId];
final isDrunk = drunkNames.contains(brand.name.toLowerCase());
// ✅ 既飲の場合、ユーザーの撮影画像を取得
String? userImagePath;
if (isDrunk) {
final matchedSake = userItemsAsync.maybeWhen(
data: (items) {
try {
return items.firstWhere(
(i) => i.displayData.displayName.toLowerCase() == brand.name.toLowerCase(),
);
} catch (e) {
return null;
}
},
orElse: () => null,
);
if (matchedSake != null && matchedSake.displayData.imagePaths.isNotEmpty) {
userImagePath = matchedSake.displayData.imagePaths.first;
}
}
return _RankingDisplayItem(
rank: ranking.rank,
brand: brand,
flavorChart: chart,
isDrunk: isDrunk,
mbtiType: userProfile.mbti,
userImagePath: userImagePath, // ✅ 追加
);
}).whereType<_RankingDisplayItem>().toList();
if (displayItems.isEmpty) {
return _buildErrorState(context, appColors);
}
return _buildCarousel(context, displayItems, appColors);
},
loading: () => _buildLoadingState(appColors),
error: (err, stack) => _buildErrorState(context, appColors),
);
},
loading: () => _buildLoadingState(appColors),
error: (err, stack) => _buildErrorState(context, appColors),
);
},
loading: () => _buildLoadingState(appColors),
error: (err, stack) => _buildErrorState(context, appColors),
),
],
);
}
Widget _buildCarousel(BuildContext context, List<_RankingDisplayItem> items, AppColors appColors) {
return Column(
children: [
SizedBox(
height: 280,
child: CarouselSlider.builder(
carouselController: _carouselController,
itemCount: items.length,
options: CarouselOptions(
height: 280,
enlargeCenterPage: true,
enlargeFactor: 0.3,
enlargeStrategy: CenterPageEnlargeStrategy.zoom,
viewportFraction: 0.45,
enableInfiniteScroll: items.length > 2,
autoPlay: false,
scrollPhysics: const BouncingScrollPhysics(),
onPageChanged: (index, reason) {
setState(() => _currentIndex = index);
},
),
itemBuilder: (context, index, realIndex) {
final item = items[index];
final distance = (_currentIndex - index).abs();
final depth = distance == 0 ? 1.0 : math.max(0.65, 1.0 - (distance * 0.18));
return _buildRankingCard(context, item, index == _currentIndex, depth, appColors);
},
),
),
const SizedBox(height: 12),
// インジケーター
_buildIndicator(items.length, appColors),
],
);
}
Widget _buildIndicator(int count, AppColors appColors) {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(
count,
(index) {
final isActive = _currentIndex == index;
return GestureDetector(
onTap: () => _carouselController.animateToPage(index),
child: AnimatedContainer(
duration: const Duration(milliseconds: 300),
curve: Curves.easeOutCubic,
width: isActive ? 20 : 8,
height: 8,
margin: const EdgeInsets.symmetric(horizontal: 3),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(4),
color: isActive ? appColors.brandPrimary : appColors.divider,
boxShadow: isActive
? [BoxShadow(color: appColors.brandPrimary.withValues(alpha: 0.4), blurRadius: 4)]
: null,
),
),
);
},
),
);
}
Widget _buildRankingCard(
BuildContext context,
_RankingDisplayItem item,
bool isCurrent,
double depth,
AppColors appColors,
) {
// MBTI相性計算簡易版 - フレーバーチャートベース)
final mbtiScore = item.mbtiType != null && item.flavorChart != null
? _calculateMbtiFlavorMatch(item.mbtiType!, item.flavorChart!)
: null;
return GestureDetector(
onTap: () => _showBrandDetail(context, item, mbtiScore, appColors),
child: AnimatedContainer(
duration: const Duration(milliseconds: 400),
curve: Curves.easeOutCubic,
margin: EdgeInsets.symmetric(
vertical: isCurrent ? 0 : 14 * (1 - depth + 0.4),
horizontal: 6,
),
transform: Matrix4.diagonal3Values(depth, depth, 1.0)
..setEntry(3, 2, 0.001),
transformAlignment: Alignment.center,
child: Card(
elevation: isCurrent ? 16 : 4 * depth,
shadowColor: Colors.black.withValues(alpha: 0.3),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(isCurrent ? 20 : 16),
),
clipBehavior: Clip.antiAliasWithSaveLayer,
child: Stack(
fit: StackFit.expand,
children: [
// ✅ 既飲画像があればそれを使用、なければグラデーション
if (item.userImagePath != null)
Image.file(
File(item.userImagePath!),
fit: BoxFit.cover,
)
else
Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
appColors.brandPrimary.withValues(alpha: 0.15),
appColors.surfaceSubtle,
],
),
),
),
// ✅ グラデーションオーバーレイ(画像の上に重ねてテキスト可読性確保)
if (item.userImagePath != null)
Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
stops: const [0.0, 0.5, 1.0],
colors: [
Colors.black.withValues(alpha: 0.3),
Colors.black.withValues(alpha: 0.5),
Colors.black.withValues(alpha: 0.8),
],
),
),
),
// コンテンツ
Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// ランキングバッジ
_buildRankBadge(item.rank, appColors),
const SizedBox(height: 8),
// 銘柄名
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
item.brand.name,
maxLines: 2,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: TextStyle(
color: item.userImagePath != null ? Colors.white : appColors.textPrimary, // ✅ 画像ある場合は白文字
fontSize: isCurrent ? 14 : 12,
fontWeight: FontWeight.bold,
shadows: item.userImagePath != null
? [const Shadow(color: Colors.black, blurRadius: 6)] // ✅ 画像背景時は影付き
: null,
),
),
if (item.flavorChart != null) ...[
const SizedBox(height: 4),
Text(
_getTopFlavors(item.flavorChart!),
style: TextStyle(
color: item.userImagePath != null ? Colors.white70 : appColors.textSecondary, // ✅ 画像ある場合は白文字
fontSize: 10,
),
),
],
],
),
),
// MBTI相性あれば
if (mbtiScore != null) ...[
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3),
decoration: BoxDecoration(
color: _getMbtiColor(mbtiScore, appColors).withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(8),
),
child: Text(
'${_getMbtiStars(mbtiScore)} ${item.mbtiType}',
style: TextStyle(
color: _getMbtiColor(mbtiScore, appColors),
fontSize: 9,
),
),
),
],
],
),
),
// 既飲バッジ
if (item.isDrunk)
Positioned(
top: 8,
right: 8,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: appColors.brandPrimary,
borderRadius: BorderRadius.circular(10),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.check, color: Colors.white, size: 12),
const SizedBox(width: 2),
const Text(
'飲んだ',
style: TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.bold,
),
),
],
),
),
),
// 中央カードのグロー効果
if (isCurrent)
Positioned(
top: 0,
left: 0,
right: 0,
height: 3,
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
Colors.transparent,
appColors.brandPrimary.withValues(alpha: 0.8),
Colors.transparent,
],
),
),
),
),
],
),
),
),
);
}
Widget _buildRankBadge(int rank, AppColors appColors) {
Color badgeColor;
switch (rank) {
case 1:
badgeColor = const Color(0xFFD4A574); // ゴールド
break;
case 2:
badgeColor = const Color(0xFF9E9A94); // シルバー
break;
case 3:
badgeColor = const Color(0xFFB8860B); // ブロンズ
break;
default:
badgeColor = appColors.textSecondary;
}
return Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: badgeColor,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: badgeColor.withValues(alpha: 0.4),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
alignment: Alignment.center,
child: Text(
'$rank',
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
);
}
String _getTopFlavors(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('');
}
// MBTI × フレーバーチャートのマッチ度全6軸活用版
double _calculateMbtiFlavorMatch(String mbtiType, SakenowaFlavorChart chart) {
// MBTICompatibilityServiceの五味好みを参照
final flavorPrefs = MBTICompatibilityService.flavorPreferences[mbtiType];
if (flavorPrefs == null) return 0.5;
// ユーザーの五味好み1-5スケールを0-1に正規化
final userAroma = ((flavorPrefs['aroma'] ?? 3) - 1) / 4;
final userSweetness = ((flavorPrefs['sweetness'] ?? 3) - 1) / 4;
final userAcidity = ((flavorPrefs['acidity'] ?? 3) - 1) / 4;
final userBitterness = ((flavorPrefs['bitterness'] ?? 3) - 1) / 4;
final userBody = ((flavorPrefs['body'] ?? 3) - 1) / 4;
// さけのわフレーバー6軸との詳細マッピング
// f1: 華やか → aroma
// f2: 芳醇 → sweetness + aroma芳醇は甘みと香りの複合
// f3: 重厚 → body
// f4: 穏やか → sweetness低acidity/bitterness
// f5: 軽快 → acidity低body
// f6: ドライ → bitterness低sweetness
double diff = 0;
diff += (userAroma - chart.f1).abs(); // 華やか → 香り
diff += (userSweetness - (chart.f2 + chart.f4) / 2).abs(); // 芳醇・穏やか → 甘み
diff += (userBody - chart.f3).abs(); // 重厚 → コク
diff += (userAcidity - chart.f5).abs(); // 軽快 → 酸味
diff += (userBitterness - chart.f6).abs(); // ドライ → キレ
// 5軸の差分合計を0-1スコアに変換
return (1.0 - (diff / 5.0)).clamp(0.3, 1.0);
}
String _getMbtiStars(double score) {
final starCount = (score * 5).round().clamp(1, 5);
return '' * starCount + '' * (5 - starCount);
}
Color _getMbtiColor(double score, AppColors appColors) {
if (score >= 0.75) return appColors.brandPrimary;
if (score >= 0.55) return appColors.textSecondary;
return appColors.textTertiary;
}
Widget _buildLoadingState(AppColors appColors) {
return SizedBox(
height: 280,
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(color: appColors.brandPrimary),
const SizedBox(height: 12),
Text(
'ランキングを取得中...',
style: TextStyle(color: appColors.textSecondary, fontSize: 12),
),
],
),
),
);
}
Widget _buildErrorState(BuildContext context, AppColors appColors) {
return SizedBox(
height: 200,
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(LucideIcons.wifiOff, color: appColors.iconSubtle, size: 32),
const SizedBox(height: 8),
Text(
'ランキングを取得できませんでした',
style: TextStyle(color: appColors.textSecondary, fontSize: 12),
),
],
),
),
);
}
void _showAttribution(BuildContext context) {
final appColors = Theme.of(context).extension<AppColors>() ?? AppColors.washiLight();
showDialog(
context: context,
builder: (context) => AlertDialog(
backgroundColor: appColors.surfaceSubtle,
title: Row(
children: [
Icon(LucideIcons.externalLink, color: appColors.brandPrimary, size: 20),
const SizedBox(width: 8),
const Text('さけのわデータ'),
],
),
content: const Text(
'このランキングは「さけのわ」のデータを利用しています。\n\n'
'https://sakenowa.com\n\n'
'※ユーザーの投稿に基づくデータです。',
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text('閉じる', style: TextStyle(color: appColors.brandPrimary)),
),
],
),
);
}
void _showBrandDetail(
BuildContext context,
_RankingDisplayItem item,
double? mbtiScore,
AppColors appColors,
) {
showModalBottomSheet(
context: context,
backgroundColor: appColors.surfaceSubtle,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (context) => Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// ヘッダー
Row(
children: [
_buildRankBadge(item.rank, appColors),
const SizedBox(width: 12),
Expanded(
child: Text(
item.brand.name,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
if (item.isDrunk)
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: appColors.brandPrimary,
borderRadius: BorderRadius.circular(16),
),
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.check, color: Colors.white, size: 14),
SizedBox(width: 4),
Text(
'飲んだ',
style: TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
],
),
),
],
),
const SizedBox(height: 20),
// MBTI相性
if (mbtiScore != null && item.mbtiType != null) ...[
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: _getMbtiColor(mbtiScore, appColors).withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Icon(LucideIcons.brainCircuit, color: appColors.brandPrimary),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${item.mbtiType}との相性',
style: TextStyle(
color: appColors.textSecondary,
fontSize: 12,
),
),
const SizedBox(height: 4),
Text(
_getMbtiStars(mbtiScore),
style: TextStyle(
color: _getMbtiColor(mbtiScore, appColors),
fontSize: 20,
letterSpacing: 2,
),
),
],
),
),
Text(
'${(mbtiScore * 100).round()}%',
style: TextStyle(
color: _getMbtiColor(mbtiScore, appColors),
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
],
),
),
const SizedBox(height: 16),
],
// フレーバーチャート
if (item.flavorChart != null) ...[
Text(
'フレーバー特徴',
style: TextStyle(
fontWeight: FontWeight.bold,
color: appColors.textPrimary,
),
),
const SizedBox(height: 12),
_buildFlavorBars(item.flavorChart!, appColors),
],
const SizedBox(height: 16),
// 帰属表示
Text(
'データ提供: さけのわ',
style: TextStyle(
fontSize: 11,
color: appColors.textTertiary,
),
),
],
),
),
);
}
Widget _buildFlavorBars(SakenowaFlavorChart chart, AppColors appColors) {
final flavors = [
('華やか', chart.f1),
('芳醇', chart.f2),
('重厚', chart.f3),
('穏やか', chart.f4),
('軽快', chart.f5),
('ドライ', chart.f6),
];
return Column(
children: flavors.map((f) => Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
children: [
SizedBox(
width: 50,
child: Text(f.$1, style: TextStyle(fontSize: 12, color: appColors.textSecondary)),
),
Expanded(
child: ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value: f.$2,
backgroundColor: appColors.divider,
color: appColors.brandPrimary,
minHeight: 8,
),
),
),
const SizedBox(width: 8),
Text(
'${(f.$2 * 100).toInt()}%',
style: TextStyle(fontSize: 11, color: appColors.textSecondary),
),
],
),
)).toList(),
);
}
}
/// 表示用データクラス
class _RankingDisplayItem {
final int rank;
final SakenowaBrand brand;
final SakenowaFlavorChart? flavorChart;
final bool isDrunk;
final String? mbtiType;
final String? userImagePath; // ✅ 追加: ユーザーの撮影画像
_RankingDisplayItem({
required this.rank,
required this.brand,
this.flavorChart,
required this.isDrunk,
this.mbtiType,
this.userImagePath, // ✅ 追加
});
}