799 lines
28 KiB
Dart
799 lines
28 KiB
Dart
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, // ✅ 追加
|
||
});
|
||
}
|