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

801 lines
30 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';
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) {
debugPrint('Share error: $e');
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('シェアに失敗しました。再度お試しください。')),
);
}
} 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 Stack(
children: [
// Ambient Glow — 右上の薄い光
Positioned(
top: -60,
right: -60,
child: IgnorePointer(
child: Container(
width: 280,
height: 280,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: RadialGradient(
colors: [
Theme.of(context).extension<AppColors>()!.brandAccent.withValues(alpha: 0.12),
Colors.transparent,
],
),
),
),
),
),
SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// テイストプロフィール
_buildSectionHeader(context, 'テイストプロフィール', LucideIcons.activity),
const SizedBox(height: 8),
Screenshot(
controller: _screenshotController,
child: _buildShukoCard(context, baseProfile, personalizedTitle, userProfile.nickname),
),
const SizedBox(height: 16),
_buildActionButtons(context),
const SizedBox(height: 32),
// MBTI風診断
_buildSectionHeader(context, 'MBTI風診断', LucideIcons.brainCircuit),
const SizedBox(height: 8),
_buildMBTIDiagnosisSection(context, userProfile, sakeList),
const SizedBox(height: 32),
// さけのわ おすすめ(各ウィジェットが独自ヘッダーを持つため親ヘッダーは不要)
const SakenowaNewRecommendationSection(displayCount: 5),
const SizedBox(height: 16),
const SakenowaRankingSection(displayCount: 10),
const SizedBox(height: 32),
],
),
), // SingleChildScrollView
], // Stack children
); // Stack
},
loading: () => const Center(child: CircularProgressIndicator()),
error: (err, stack) => ErrorRetryWidget(
message: 'AIソムリエ診断の読み込みに失敗しました',
details: err.toString(),
onRetry: () => ref.refresh(allSakeItemsProvider),
),
),
);
}
Widget _buildSectionHeader(BuildContext context, String title, IconData icon) {
final appColors = Theme.of(context).extension<AppColors>()!;
return Row(
children: [
Icon(icon, size: 20, color: appColors.iconDefault),
const SizedBox(width: 8),
Text(
title,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
color: appColors.textPrimary,
),
),
],
);
}
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,
child: const Text(
'🍶',
style: TextStyle(fontSize: 40),
),
),
),
Padding(
padding: const EdgeInsets.all(24.0),
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 SizedBox(
width: double.infinity,
child: 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(vertical: 16),
backgroundColor: appColors.brandPrimary,
foregroundColor: appColors.surfaceSubtle,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
),
),
);
}
// --- MBTI Diagnosis Logic ---
Widget _buildMBTIDiagnosisSection(BuildContext context, UserProfile userProfile, List<SakeItem> sakeList) {
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),
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: isDark
? [const Color(0xFF2C2C2E), const Color(0xFF1C1C1E)]
: [appColors.surfaceSubtle, appColors.surfaceElevated],
),
boxShadow: [
BoxShadow(
color: isDark
? Colors.black.withValues(alpha: 0.5)
: Colors.black.withValues(alpha: 0.08),
blurRadius: 20,
offset: const Offset(0, 10),
),
],
),
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(const SnackBar(content: Text('診断に失敗しました。時間をおいて再試行してください。')));
}
}
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,
),
),
],
),
);
}
}