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

799 lines
28 KiB
Dart
Raw Normal View History

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, // ✅ 追加
});
}