2026-02-15 15:13:12 +00:00
|
|
|
|
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),
|
2026-02-21 01:32:02 +00:00
|
|
|
|
error: (err, stack) => _buildErrorState(context, appColors),
|
2026-02-15 15:13:12 +00:00
|
|
|
|
);
|
|
|
|
|
|
},
|
|
|
|
|
|
loading: () => _buildLoadingState(appColors),
|
2026-02-21 01:32:02 +00:00
|
|
|
|
error: (err, stack) => _buildErrorState(context, appColors),
|
2026-02-15 15:13:12 +00:00
|
|
|
|
);
|
|
|
|
|
|
},
|
|
|
|
|
|
loading: () => _buildLoadingState(appColors),
|
2026-02-21 01:32:02 +00:00
|
|
|
|
error: (err, stack) => _buildErrorState(context, appColors),
|
2026-02-15 15:13:12 +00:00
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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, // ✅ 追加
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|