ponshu-room-lite/lib/screens/features/sommelier_screen.dart

747 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 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:screenshot/screenshot.dart';
import 'package:share_plus/share_plus.dart';
import 'package:path_provider/path_provider.dart';
import '../../providers/sake_list_provider.dart';
import '../../services/shuko_diagnosis_service.dart';
import '../../providers/theme_provider.dart'; // v1.1 Fix
import '../../theme/app_colors.dart';
import '../../widgets/sake_radar_chart.dart';
import '../../widgets/contextual_help_icon.dart';
import '../../services/mbti_diagnosis_service.dart';
import '../../widgets/mbti/mbti_result_card.dart';
import '../../widgets/mbti_analyzing_dialog.dart';
import '../../services/mbti_types.dart';
import '../../models/user_profile.dart'; // Ensure UserProfile is available
import '../../models/sake_item.dart'; // Ensure SakeItem is available
import '../../constants/app_constants.dart';
import '../../widgets/sakenowa/sakenowa_ranking_section.dart';
import '../../widgets/sakenowa/sakenowa_new_recommendation_section.dart';
import '../../widgets/common/error_retry_widget.dart';
class SommelierScreen extends ConsumerStatefulWidget {
const SommelierScreen({super.key});
@override
ConsumerState<SommelierScreen> createState() => _SommelierScreenState();
}
class _SommelierScreenState extends ConsumerState<SommelierScreen> {
final ScreenshotController _screenshotController = ScreenshotController();
bool _isSharing = false;
Future<void> _shareCard() async {
setState(() {
_isSharing = true;
});
try {
final image = await _screenshotController.capture(
delay: const Duration(milliseconds: 10),
pixelRatio: 3.0, // High res for sharing
);
if (image == null) return;
final directory = await getTemporaryDirectory();
final imagePath = await File('${directory.path}/sommelier_card.png').create();
await imagePath.writeAsBytes(image);
// Share the file (using deprecated Share API - migration to SharePlus planned)
// ignore: deprecated_member_use
await Share.shareXFiles(
[XFile(imagePath.path)],
text: '私の酒向タイプはこれ! #ポンシュルーム',
);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('シェアに失敗しました: $e')),
);
}
} finally {
if (mounted) {
setState(() {
_isSharing = false;
});
}
}
}
@override
Widget build(BuildContext context) {
final sakeListAsync = ref.watch(allSakeItemsProvider); // v1.2: 全件対象(フィルタ無視)
final userProfile = ref.watch(userProfileProvider); // v1.1
final diagnosisService = ref.watch(shukoDiagnosisServiceProvider);
return Scaffold(
appBar: AppBar(
title: const Text('AIソムリエ診断'),
centerTitle: true,
),
body: sakeListAsync.when(
data: (sakeList) {
final baseProfile = diagnosisService.diagnose(sakeList);
// Personalize Title
final personalizedTitle = diagnosisService.personalizeTitle(
ShukoTitle(title: baseProfile.title, description: baseProfile.description),
userProfile.gender
);
return SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 16.0),
child: Column(
children: [
/* Greeting Removed */
// const SizedBox(height: 8),
Screenshot(
controller: _screenshotController,
child: _buildShukoCard(context, baseProfile, personalizedTitle, userProfile.nickname), // Pass nickname
),
const SizedBox(height: 16), // Card下
_buildActionButtons(context),
const SizedBox(height: 8), // ボタン下(チラ見せ強化)
const Divider(),
const SizedBox(height: 16), // 区切り線下
// --- New: MBTI Diagnosis Section ---
_buildMBTIDiagnosisSection(context, userProfile, sakeList),
const SizedBox(height: 24),
const Divider(),
const SizedBox(height: 16),
// --- New: New Sake Recommendations Section ---
const SakenowaNewRecommendationSection(displayCount: 5),
const SizedBox(height: 24),
const Divider(),
const SizedBox(height: 16),
// --- New: Sakenowa Ranking Section ---
const SakenowaRankingSection(displayCount: 10),
const SizedBox(height: 32),
],
),
);
},
loading: () => const Center(child: CircularProgressIndicator()),
error: (err, stack) => ErrorRetryWidget(
message: 'AIソムリエ診断の読み込みに失敗しました',
details: err.toString(),
onRetry: () => ref.refresh(allSakeItemsProvider),
),
),
);
}
Widget _buildShukoCard(BuildContext context, ShukoProfile profile, ShukoTitle titleInfo, String? nickname) {
final appColors = Theme.of(context).extension<AppColors>()!;
final isDark = Theme.of(context).brightness == Brightness.dark;
return Container(
width: double.infinity,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(24),
border: isDark
? null
: Border.all(color: appColors.divider.withValues(alpha: 0.5), width: 1), // Add border for light mode visibility
// Premium Card Gradient (Adjusted for Dark Mode visibility)
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: isDark
? [
// Dark Mode: Lighter grey for visibility against black background
const Color(0xFF2C2C2E),
const Color(0xFF1C1C1E),
]
: [
// Light Mode: Original subtle gradient
appColors.surfaceSubtle,
appColors.surfaceElevated,
],
),
boxShadow: [
BoxShadow(
color: isDark
? Colors.black.withValues(alpha: 0.5) // Deeper shadow for dark mode
: Colors.black.withValues(alpha: 0.08), // Stronger shadow for light mode (was divider.withValues(alpha: 0.3))
blurRadius: 20,
offset: const Offset(0, 10),
),
],
),
child: Stack(
children: [
// Background Pattern (Optional subtle decoration)
Positioned(
right: -20,
top: -20,
child: Icon(
LucideIcons.sparkles,
size: 150,
color: appColors.brandAccent.withValues(alpha: 0.05),
),
),
// Subtle Sake Emoji
Positioned(
left: 20,
top: 20,
child: Opacity(
opacity: 0.3, // Subtle
child: const Text(
'🍶',
style: TextStyle(fontSize: 40),
),
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20.0, vertical: 12.0), // Optimized for チラ見せ effect
child: Column(
children: [
// 1. Header (Name & Rank) with unified help icon
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
(nickname != null && nickname.isNotEmpty)
? '$nicknameさんの酒向タイプ'
: 'あなたの酒向タイプ',
style: Theme.of(context).textTheme.bodySmall?.copyWith(letterSpacing: 1.5, color: appColors.textSecondary),
),
const SizedBox(width: 4),
ContextualHelpIcon(
title: '酒向タイプ・チャートの見方',
customContent: _buildUnifiedHelpContent(context),
),
],
),
const SizedBox(height: 8),
// 2. Title (no help icon - moved to header)
Text(
titleInfo.title,
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 32,
fontWeight: FontWeight.bold,
color: appColors.brandPrimary,
shadows: [
Shadow(
color: appColors.brandPrimary.withValues(alpha: 0.3),
blurRadius: 10,
offset: const Offset(0, 2),
),
],
),
),
const SizedBox(height: 8),
Text(
titleInfo.description,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
height: 1.5,
color: appColors.textPrimary,
),
),
const SizedBox(height: 32),
SizedBox(
height: 200,
child: SakeRadarChart(
tasteStats: {
'aroma': (profile.avgStats.aroma).round(),
'sweetness': (profile.avgStats.sweetness).round(),
'acidity': (profile.avgStats.acidity).round(),
'bitterness': (profile.avgStats.bitterness).round(),
'body': (profile.avgStats.body).round(),
},
primaryColor: appColors.brandPrimary,
),
),
const SizedBox(height: 24),
// 4. Stats Footer
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: appColors.surfaceSubtle.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(12),
),
child: Text(
'分析対象: ${profile.analyzedCount}',
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: appColors.textSecondary),
),
),
],
),
),
],
),
);
}
Widget _buildActionButtons(BuildContext context) {
final appColors = Theme.of(context).extension<AppColors>()!;
return Column(
children: [
// SizedBox(width: double.infinity) removed to allow button to size itself
FilledButton.icon(
onPressed: _isSharing ? null : _shareCard,
icon: _isSharing
? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2))
: const Icon(LucideIcons.share2),
label: const Text(
'シェア',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 48, vertical: 16), // Wide padding for "Pill" look
backgroundColor: appColors.brandPrimary,
foregroundColor: appColors.surfaceSubtle,
shape: const StadiumBorder(),
),
),
],
);
}
// --- MBTI Diagnosis Logic ---
Widget _buildMBTIDiagnosisSection(BuildContext context, UserProfile userProfile, List<SakeItem> sakeList) {
final appColors = Theme.of(context).extension<AppColors>()!;
return Card(
elevation: 2,
color: appColors.surfaceSubtle,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
children: [
Icon(LucideIcons.sparkles, size: 40, color: appColors.brandAccent),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: Text(
'酒向タイプ診断', // Removed (MBTI風)
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
color: appColors.brandPrimary,
),
textAlign: TextAlign.center,
),
),
const SizedBox(width: 4),
ContextualHelpIcon(
title: 'MBTI風診断について',
customContent: _buildMBTIHelpContent(context),
),
],
),
const SizedBox(height: 8),
Text(
'AIがあなたの飲酒スタイルを分析',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: appColors.textSecondary),
),
const SizedBox(height: 24),
// Result or Prompt
if (userProfile.sakePersonaMbti != null) ...[
// Already Diagnosed
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: appColors.surfaceElevated,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: appColors.brandAccent.withValues(alpha: 0.3)),
),
child: Column(
children: [
Text('あなたの診断結果', style: TextStyle(fontSize: 12, color: appColors.textSecondary)),
const SizedBox(height: 8),
Text(
MBTIType.types[userProfile.sakePersonaMbti]?.title ?? userProfile.sakePersonaMbti!,
style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
),
],
),
),
const SizedBox(height: 24),
],
// Action Button
FilledButton.icon(
onPressed: () => _runMBTIDiagnosis(context, sakeList),
icon: const Icon(LucideIcons.brainCircuit),
label: Text(
userProfile.sakePersonaMbti == null ? '診断を開始する' : '再診断する',
style: const TextStyle(fontWeight: FontWeight.bold),
),
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 16),
backgroundColor: appColors.brandPrimary,
foregroundColor: Theme.of(context).colorScheme.onPrimary,
shape: const StadiumBorder(), // Consistent shape
),
),
if (sakeList.length < AppConstants.mbtiMinimumRecords)
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Text(
'※診断には${AppConstants.mbtiMinimumRecords}本以上の記録が必要です (現在: ${sakeList.length}本)',
style: TextStyle(color: appColors.error, fontSize: 11),
),
),
],
),
),
);
}
Future<void> _runMBTIDiagnosis(BuildContext context, List<SakeItem> sakeList) async {
// 1. Check Data Count
if (sakeList.length < AppConstants.mbtiMinimumRecords) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('データ不足です!あと${AppConstants.mbtiMinimumRecords - sakeList.length}本の記録が必要です。'),
backgroundColor: Theme.of(context).colorScheme.error,
duration: const Duration(seconds: 4),
),
);
return;
}
// 2. BuildContextキャプチャasync gapの前
if (!mounted) return;
final navigator = Navigator.of(context);
final messenger = ScaffoldMessenger.of(context);
// 3. Show MBTI Analyzing Dialog
showDialog(
context: context,
barrierDismissible: false,
builder: (_) => const MBTIAnalyzingDialog(),
);
try {
// 4. Simulate delay
final minWait = Future.delayed(const Duration(seconds: 3));
// Logic
final service = MBTIDiagnosisService();
final result = service.diagnose(sakeList);
await minWait;
// 5. async gap後はキャプチャしたnavigatorを使用
if (!mounted) return;
navigator.pop(); // ✅ BuildContextを直接使わない
// 6. Show Result Card
if (!mounted) return;
// contextは関数開始時async gap前にパラメータとして受け取り済み
// showDialogは新しいWidgetツリーを構築するだけなので技術的に安全
// ignore: use_build_context_synchronously
await showDialog(
// ignore: use_build_context_synchronously
context: context,
builder: (dialogContext) => Dialog(
backgroundColor: Colors.transparent,
insetPadding: const EdgeInsets.all(16),
child: MBTIResultCard(
result: result,
onShare: () {
if (!mounted) return;
messenger.showSnackBar(const SnackBar(content: Text('シェア機能は開発中です!')));
},
onShowRecommendations: () {
navigator.pop(); // dialogContextではなくnavigatorを使用
// Save Result to "SakePersona" field (not Real MBTI)
ref.read(userProfileProvider.notifier).setSakePersonaMbti(result.type.code);
if (!mounted) return;
messenger.showSnackBar(
SnackBar(
content: Text('${result.type.title}」として診断結果を保存しました!'),
duration: const Duration(seconds: 3),
),
);
},
),
),
);
} catch (e) {
debugPrint('Diagnosis Error: $e');
if (!mounted) return;
navigator.pop();
messenger.showSnackBar(SnackBar(content: Text('エラー: $e')));
}
}
Widget _buildUnifiedHelpContent(BuildContext context) {
final appColors = Theme.of(context).extension<AppColors>()!;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Section 1: 酒向タイプとは
Text(
'酒向タイプとは?',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
color: appColors.brandPrimary,
),
),
const SizedBox(height: 8),
Text(
'あなたのテイスティング記録から、AIが分析した「好みの傾向」を称号で表します。',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
height: 1.6,
),
),
const SizedBox(height: 16),
Text(
'主な称号',
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
color: appColors.brandPrimary,
),
),
const SizedBox(height: 8),
_buildTitleExample(context, '辛口サムライ', 'キレのある辛口がお好み'),
_buildTitleExample(context, 'フルーティーマスター', '華やかな香りと甘みを愛する'),
_buildTitleExample(context, '旨口探求者', 'お米の旨みとコクを重視'),
_buildTitleExample(context, '香りの貴族', '吟醸香など華やかな香りを楽しむ'),
_buildTitleExample(context, 'バランスの賢者', '様々なタイプを楽しむオールラウンダー'),
_buildTitleExample(context, '酒道の旅人', 'これから自分だけの味を見つける冒険者'),
const SizedBox(height: 12),
Text(
'※ 記録が増えるほど、より正確な診断結果が得られます。\n※ 女性の方には一部の称号が変化します(例: サムライ→麗人)',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: appColors.textSecondary,
height: 1.5,
),
),
// Divider between sections
const SizedBox(height: 24),
Divider(color: appColors.divider, thickness: 1),
const SizedBox(height: 16),
// Section 2: チャートの見方
Text(
'チャートの見方',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
color: appColors.brandPrimary,
),
),
const SizedBox(height: 8),
Text(
'AIがあなたの登録した日本酒の味覚データを分析し、好みの傾向をチャート化します。',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
height: 1.6,
),
),
const SizedBox(height: 16),
_buildChartAxisExplanation(context, '香り', '華やかな吟醸香、フルーティーさ'),
_buildChartAxisExplanation(context, '甘み', '口当たりの甘さ、まろやかさ'),
_buildChartAxisExplanation(context, '酸味', '爽やかな酸味、キレの良さ'),
_buildChartAxisExplanation(context, 'キレ', '後味のキレ、ドライ感 (旧:苦味)'),
_buildChartAxisExplanation(context, 'コク', 'お米の旨味、ボディ感'),
],
);
}
Widget _buildTitleExample(BuildContext context, String title, String description) {
final appColors = Theme.of(context).extension<AppColors>()!;
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
margin: const EdgeInsets.only(top: 6),
width: 6,
height: 6,
decoration: BoxDecoration(
color: appColors.brandAccent,
shape: BoxShape.circle,
),
),
const SizedBox(width: 12),
Expanded(
child: RichText(
text: TextSpan(
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: appColors.textPrimary),
children: [
TextSpan(
text: '$title: ',
style: const TextStyle(fontWeight: FontWeight.bold),
),
TextSpan(text: description),
],
),
),
),
],
),
);
}
Widget _buildChartAxisExplanation(BuildContext context, String axis, String description) {
final appColors = Theme.of(context).extension<AppColors>()!;
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Row(
children: [
Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: appColors.brandPrimary,
shape: BoxShape.circle,
),
),
const SizedBox(width: 12),
Expanded(
child: RichText(
text: TextSpan(
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: appColors.textPrimary),
children: [
TextSpan(
text: '$axis: ',
style: const TextStyle(fontWeight: FontWeight.bold),
),
TextSpan(text: description),
],
),
),
),
],
),
);
}
Widget _buildMBTIHelpContent(BuildContext context) {
final appColors = Theme.of(context).extension<AppColors>()!;
return SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'MBTI風診断について',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
color: appColors.brandPrimary,
),
),
const SizedBox(height: 8),
Text(
'あなたの日本酒の好みから、16種類の「酒向タイプ」を診断します。\nMBTI性格診断をモチーフにした、遊び心のある分析です。',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
height: 1.6,
),
),
const SizedBox(height: 16),
Text(
'16種類のタイプ一覧',
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
color: appColors.brandPrimary,
),
),
const SizedBox(height: 12),
// Generate list from MBTIType.types
...MBTIType.types.entries.map((entry) {
final type = entry.value;
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: appColors.surfaceElevated,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: appColors.divider.withValues(alpha: 0.3)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: appColors.brandAccent.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(4),
),
child: Text(
type.code,
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: appColors.brandAccent,
),
),
),
const SizedBox(width: 8),
Expanded(
child: Text(
type.title,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
),
),
),
],
),
const SizedBox(height: 4),
Text(
type.catchphrase,
style: TextStyle(
fontSize: 11,
fontStyle: FontStyle.italic,
color: appColors.textSecondary,
),
),
const SizedBox(height: 4),
Text(
'推奨: ${type.recommendedStyles}',
style: TextStyle(
fontSize: 10,
color: appColors.textSecondary,
),
),
],
),
),
);
}),
const SizedBox(height: 8),
Text(
'※ 診断には${AppConstants.mbtiMinimumRecords}本以上の記録が必要です。\n※ より多くのデータを記録すると、診断精度が向上します。',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: appColors.textSecondary,
height: 1.5,
),
),
],
),
);
}
}