ponshu-room-lite/lib/screens/sake_detail_screen.dart

802 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/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lucide_icons/lucide_icons.dart';
import '../models/sake_item.dart';
import '../services/gemini_service.dart';
import '../providers/gemini_provider.dart';
import '../services/sake_recommendation_service.dart';
import '../widgets/analyzing_dialog.dart';
import '../widgets/sake_3d_carousel_with_reason.dart';
import 'package:hive_flutter/hive_flutter.dart';
import '../providers/sake_list_provider.dart';
import 'sake_detail/sections/sake_pricing_section.dart';
import '../theme/app_colors.dart';
import '../constants/app_constants.dart';
import '../widgets/sake_detail/sake_detail_chart.dart';
import '../widgets/sake_detail/sake_detail_memo.dart';
import '../widgets/sake_detail/sake_detail_specs.dart';
import 'sake_detail/widgets/sake_photo_edit_modal.dart';
import 'sake_detail/sections/sake_mbti_stamp_section.dart';
import '../providers/license_provider.dart';
import 'sake_detail/sections/sake_basic_info_section.dart';
import 'sake_detail/widgets/sake_detail_sliver_app_bar.dart';
import '../services/mbti_compatibility_service.dart';
import '../widgets/sakenowa/sakenowa_detail_recommendation_section.dart';
// Note: google_fonts and theme_provider are now used in sake_basic_info_section.dart
class SakeDetailScreen extends ConsumerStatefulWidget {
final SakeItem sake;
const SakeDetailScreen({super.key, required this.sake});
@override
ConsumerState<SakeDetailScreen> createState() => _SakeDetailScreenState();
}
class _SakeDetailScreenState extends ConsumerState<SakeDetailScreen> {
// To trigger rebuilds if we don't switch to a stream
late SakeItem _sake;
int _currentImageIndex = 0;
// Memo logic moved to SakeDetailMemo
@override
void initState() {
super.initState();
_sake = widget.sake;
// Memo init removed
// AI分析情報の編集用コントローラーを初期化
}
@override
void dispose() {
// Memo dispose removed
// AI分析情報の編集用コントローラーを破棄
super.dispose();
}
/// 五味チャートの値を手動更新し、Hiveに永続化
Future<void> _updateTasteStats(Map<String, int> newStats) async {
final updatedSake = _sake.copyWith(
tasteStats: newStats,
isUserEdited: true,
);
final box = Hive.box<SakeItem>('sake_items');
await box.put(_sake.key, updatedSake);
setState(() {
_sake = updatedSake;
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('チャートを更新しました'),
duration: Duration(seconds: 2),
),
);
}
}
@override
Widget build(BuildContext context) {
final appColors = Theme.of(context).extension<AppColors>()!;
final isPro = ref.watch(isProProvider);
// スマートレコメンド
final allSakeAsync = ref.watch(allSakeItemsProvider);
final allSake = allSakeAsync.asData?.value ?? [];
// 新しいレコメンドエンジン使用(五味チャート類似度込み)
final recommendations = SakeRecommendationService.getRecommendations(
target: _sake,
allItems: allSake,
limit: AppConstants.recommendationLimit,
);
final relatedItems = recommendations.map((rec) => rec.item).toList();
return Scaffold(
body: CustomScrollView(
slivers: [
SakeDetailSliverAppBar(
sake: _sake,
currentImageIndex: _currentImageIndex,
onToggleFavorite: _toggleFavorite,
onReanalyze: () => _reanalyze(context),
onDelete: () {
HapticFeedback.heavyImpact();
_showDeleteDialog(context);
},
onPageChanged: (index) => setState(() => _currentImageIndex = index),
onPhotoEdit: () => _showPhotoEditModal(context),
),
SliverToBoxAdapter(
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: Theme.of(context).brightness == Brightness.dark
? [
const Color(0xFF121212), // Scaffold Background
const Color(0xFF1E1E1E), // Slightly lighter surface
]
: [
Theme.of(context).scaffoldBackgroundColor,
Theme.of(context).primaryColor.withValues(alpha: 0.05),
],
),
),
padding: const EdgeInsets.all(24.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Basic Info: AI確信度・MBTI相性・銘柄名・蔵元・タグ・キャッチコピー (Extracted)
SakeBasicInfoSection(
sake: _sake,
onTapName: () => _showTextEditDialog(
context,
title: '銘柄名を編集',
initialValue: _sake.displayData.displayName,
onSave: (value) async {
final box = Hive.box<SakeItem>('sake_items');
final updated = _sake.copyWith(name: value, isUserEdited: true);
await box.put(_sake.key, updated);
setState(() => _sake = updated);
},
),
onTapBrewery: () => _showBreweryEditDialog(context),
onTapTags: () => _showTagEditDialog(context),
onTapMbtiCompatibility: _showMbtiCompatibilityDialog,
),
const SizedBox(height: 32),
// Taste Radar Chart
SakeDetailChart(
sake: _sake,
onTasteStatsEdited: (newStats) => _updateTasteStats(newStats),
),
const SizedBox(height: 32),
// Description
if (_sake.hiddenSpecs.description != null && _sake.itemType != ItemType.set)
Text(
_sake.hiddenSpecs.description!,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
height: 1.8,
fontSize: 16,
),
),
const SizedBox(height: 32),
// AI Specs Accordion
SakeDetailSpecs(
sake: _sake,
onUpdate: (updatedSake) {
setState(() => _sake = updatedSake);
},
),
const SizedBox(height: 24),
// Memo Field
SakeDetailMemo(
initialMemo: _sake.userData.memo,
onUpdate: (value) async {
final box = Hive.box<SakeItem>('sake_items');
final updated = _sake.copyWith(memo: value, isUserEdited: true);
await box.put(_sake.key, updated);
setState(() => _sake = updated);
},
),
const SizedBox(height: 48),
// Related Items 3D Carousel
if (_sake.itemType != ItemType.set) ...[
Row(
children: [
Icon(LucideIcons.sparkles, size: 16, color: appColors.iconDefault),
const SizedBox(width: 8),
Text(
'おすすめの日本酒',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
color: appColors.textPrimary,
),
),
],
),
const SizedBox(height: 6),
Text(
'五味チャート・タグ・酒蔵・産地から自動選出',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: appColors.textSecondary,
),
),
const SizedBox(height: 16),
relatedItems.isNotEmpty
? Sake3DCarouselWithReason(
recommendations: recommendations.take(6).toList(),
height: 260,
)
: Container(
height: 120,
alignment: Alignment.center,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(LucideIcons.info, color: appColors.iconSubtle, size: 32),
const SizedBox(height: 8),
Text(
'関連する日本酒を追加すると\nおすすめが表示されます',
textAlign: TextAlign.center,
style: TextStyle(color: appColors.textSecondary, fontSize: 12),
),
],
),
),
const SizedBox(height: 24),
// さけのわ連携おすすめ(未飲銘柄)
SakenowaDetailRecommendationSection(
currentSakeName: _sake.displayData.displayName,
currentTasteData: _sake.hiddenSpecs.activeTasteData,
displayCount: 3,
),
const SizedBox(height: 48),
],
// MBTI Diagnostic Stamp Section (Pro only)
if (isPro) ...[
SakeMbtiStampSection(sake: _sake),
const SizedBox(height: 24),
],
],
),
),
),
// Business Pricing Section
SliverToBoxAdapter(
child: SakePricingSection(
sake: _sake,
onUpdated: (updated) => setState(() => _sake = updated),
),
),
// Gap with Safe Area
SliverPadding(
padding: EdgeInsets.only(bottom: MediaQuery.paddingOf(context).bottom),
),
],
),
);
}
bool _isAnalyzing = false;
DateTime? _quotaLockoutTime;
Future<void> _toggleFavorite() async {
HapticFeedback.mediumImpact();
final box = Hive.box<SakeItem>('sake_items');
final messenger = ScaffoldMessenger.of(context);
final newItem = _sake.copyWith(isFavorite: !_sake.userData.isFavorite);
await box.put(_sake.key, newItem);
setState(() {
_sake = newItem;
});
messenger.showSnackBar(
SnackBar(
content: Text(newItem.userData.isFavorite ? 'お気に入りに追加しました' : 'お気に入りを解除しました'),
duration: const Duration(milliseconds: 1000),
),
);
}
Future<void> _reanalyze(BuildContext context) async {
// 1. Check Locks
if (_isAnalyzing) return;
// 2. Check Quota Lockout
if (_quotaLockoutTime != null) {
final remaining = _quotaLockoutTime!.difference(DateTime.now());
if (remaining.isNegative) {
setState(() => _quotaLockoutTime = null); // Reset if time passed
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('AI利用制限中です。あと${remaining.inSeconds}秒お待ちください。')),
);
return;
}
}
if (_sake.displayData.imagePaths.isEmpty) return;
// 実ファイルの存在確認削除済み画像でAPIエラーになるのを防ぐ
final existingPaths = <String>[];
for (final path in _sake.displayData.imagePaths) {
if (await File(path).exists()) {
existingPaths.add(path);
}
}
if (!mounted) return;
final nav = Navigator.of(context);
final messenger = ScaffoldMessenger.of(context);
if (existingPaths.isEmpty) {
messenger.showSnackBar(
const SnackBar(content: Text('画像ファイルが見つかりません。再解析できません。')),
);
return;
}
setState(() => _isAnalyzing = true);
try {
showDialog(
context: context,
barrierDismissible: false,
builder: (context) => const AnalyzingDialog(),
);
final geminiService = ref.read(geminiServiceProvider);
final result = await geminiService.analyzeSakeLabel(existingPaths, forceRefresh: true);
final newItem = _sake.copyWith(
name: result.name ?? _sake.displayData.displayName,
brand: result.brand ?? _sake.displayData.displayBrewery,
prefecture: result.prefecture ?? _sake.displayData.displayPrefecture,
description: result.description ?? _sake.hiddenSpecs.description,
catchCopy: result.catchCopy ?? _sake.displayData.catchCopy,
confidenceScore: result.confidenceScore,
flavorTags: result.flavorTags.isNotEmpty ? result.flavorTags : _sake.hiddenSpecs.flavorTags,
tasteStats: result.tasteStats.isNotEmpty ? result.tasteStats : _sake.hiddenSpecs.tasteStats,
// New Fields
specificDesignation: result.type ?? _sake.hiddenSpecs.type,
alcoholContent: result.alcoholContent ?? _sake.hiddenSpecs.alcoholContent,
polishingRatio: result.polishingRatio ?? _sake.hiddenSpecs.polishingRatio,
sakeMeterValue: result.sakeMeterValue ?? _sake.hiddenSpecs.sakeMeterValue,
riceVariety: result.riceVariety ?? _sake.hiddenSpecs.riceVariety,
yeast: result.yeast ?? _sake.hiddenSpecs.yeast,
manufacturingYearMonth: result.manufacturingYearMonth ?? _sake.hiddenSpecs.manufacturingYearMonth,
itemType: ItemType.sake,
);
final box = Hive.box<SakeItem>('sake_items');
await box.put(_sake.key, newItem);
setState(() {
_sake = newItem;
});
if (mounted) {
nav.pop(); // Close dialog
messenger.showSnackBar(
const SnackBar(content: Text('再解析が完了しました')),
);
}
} catch (e) {
if (mounted) {
nav.pop(); // Close dialog
// Check for Quota Error to set Lockout
if (e.toString().contains('Quota') || e.toString().contains('429')) {
setState(() {
_quotaLockoutTime = DateTime.now().add(const Duration(minutes: 1));
});
}
messenger.showSnackBar(
SnackBar(content: Text('エラー: $e')),
);
}
} finally {
if (mounted) {
setState(() => _isAnalyzing = false);
}
}
}
void _showTagEditDialog(BuildContext context) {
final TextEditingController tagController = TextEditingController();
final allTags = _sake.hiddenSpecs.flavorTags.toSet();
showDialog(
context: context,
builder: (ctx) => StatefulBuilder(
builder: (context, setModalState) {
return AlertDialog(
title: const Text('タグ編集'),
contentPadding: const EdgeInsets.fromLTRB(24, 20, 24, 0),
content: ConstrainedBox(
constraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width * 0.85,
minWidth: 300,
),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Wrap(
spacing: 8,
runSpacing: 8,
children: allTags.map((tag) => Chip(
label: Text(tag),
onDeleted: () {
setModalState(() => allTags.remove(tag));
},
)).toList(),
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: TextField(
controller: tagController,
decoration: const InputDecoration(
hintText: '新しいタグを追加',
isDense: true,
),
onSubmitted: (val) {
if (val.trim().isNotEmpty) {
setModalState(() {
allTags.add(val.trim());
tagController.clear();
});
}
},
),
),
IconButton(
icon: const Icon(LucideIcons.plus),
onPressed: () {
if (tagController.text.trim().isNotEmpty) {
setModalState(() {
allTags.add(tagController.text.trim());
tagController.clear();
});
}
},
),
],
),
],
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('キャンセル'),
),
TextButton(
onPressed: () {
_updateTags(allTags.toList());
Navigator.pop(context);
},
child: const Text('保存'),
),
],
);
}
),
);
}
Future<void> _updateTags(List<String> newTags) async {
final box = Hive.box<SakeItem>('sake_items');
final newItem = _sake.copyWith(
flavorTags: newTags,
isUserEdited: true,
);
await box.put(_sake.key, newItem);
setState(() => _sake = newItem);
}
Future<void> _showDeleteDialog(BuildContext context) async {
final navigator = Navigator.of(context);
final messenger = ScaffoldMessenger.of(context);
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Row(
children: [
Icon(LucideIcons.alertTriangle, color: Theme.of(context).extension<AppColors>()!.warning, size: 24),
const SizedBox(width: 8),
const Text('削除確認'),
],
),
content: Text('${_sake.displayData.displayName}」を削除しますか?\nこの操作は取り消せません。'),
actions: [
TextButton(
child: const Text('キャンセル'),
onPressed: () => Navigator.pop(context, false),
),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: Theme.of(context).extension<AppColors>()!.error,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 16),
),
onPressed: () => Navigator.pop(context, true),
child: const Text('削除'),
),
],
),
);
if (confirmed == true && mounted) {
// nav/messenger captured above
// Day 5: 画像ファイルを削除(ストレージクリーンアップ)
for (final imagePath in _sake.displayData.imagePaths) {
try {
final imageFile = File(imagePath);
if (await imageFile.exists()) {
await imageFile.delete();
debugPrint('🗑️ Deleted image file: $imagePath');
}
} catch (e) {
debugPrint('⚠️ Failed to delete image file: $imagePath - $e');
}
}
// Hiveから削除
final box = Hive.box<SakeItem>('sake_items');
await box.delete(_sake.key);
if (mounted) {
navigator.pop(); // Return to previous screen
messenger.showSnackBar(
const SnackBar(content: Text('削除しました')),
);
}
}
}
/// テキスト編集ダイアログを表示
Future<void> _showTextEditDialog(
BuildContext context, {
required String title,
required String initialValue,
required Future<void> Function(String) onSave,
}) async {
final controller = TextEditingController(text: initialValue);
await showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(title),
content: TextField(
controller: controller,
decoration: const InputDecoration(
border: OutlineInputBorder(),
),
autofocus: true,
maxLines: null,
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('キャンセル'),
),
ElevatedButton(
onPressed: () async {
await onSave(controller.text);
if (context.mounted) Navigator.pop(context);
},
child: const Text('保存'),
),
],
),
);
}
/// MBTI相性詳細ダイアログを表示
void _showMbtiCompatibilityDialog(
BuildContext context,
CompatibilityResult result,
AppColors appColors,
) {
final starColor = result.starRating >= 4
? appColors.brandPrimary
: result.starRating >= 3
? appColors.textSecondary
: appColors.textTertiary;
showDialog(
context: context,
builder: (context) => AlertDialog(
backgroundColor: appColors.surfaceSubtle,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
title: Row(
children: [
Icon(LucideIcons.brainCircuit, color: appColors.brandPrimary, size: 24),
const SizedBox(width: 8),
Text(
'${result.mbtiType}との相性',
style: TextStyle(
color: appColors.textPrimary,
fontWeight: FontWeight.bold,
),
),
],
),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Star Rating
Text(
result.starDisplay,
style: TextStyle(
color: starColor,
fontSize: 32,
letterSpacing: 4,
),
),
const SizedBox(height: 8),
// Percentage & Level
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'${result.percent}%',
style: TextStyle(
color: starColor,
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
const SizedBox(width: 12),
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: starColor.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(12),
),
child: Text(
result.level,
style: TextStyle(
color: starColor,
fontWeight: FontWeight.bold,
fontSize: 14,
),
),
),
],
),
const SizedBox(height: 20),
// Match Reasons
if (result.reasons.isNotEmpty) ...[
Align(
alignment: Alignment.centerLeft,
child: Text(
'マッチ理由',
style: TextStyle(
color: appColors.textSecondary,
fontSize: 12,
),
),
),
const SizedBox(height: 8),
...result.reasons.take(3).map((reason) => Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Row(
children: [
Icon(LucideIcons.check, size: 14, color: appColors.brandPrimary),
const SizedBox(width: 8),
Expanded(
child: Text(
reason,
style: TextStyle(
color: appColors.textPrimary,
fontSize: 14,
),
),
),
],
),
)),
],
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text('閉じる', style: TextStyle(color: appColors.brandPrimary)),
),
],
),
);
}
/// 酒蔵・都道府県編集ダイアログを表示
Future<void> _showBreweryEditDialog(BuildContext context) async {
final breweryController = TextEditingController(text: _sake.displayData.displayBrewery);
final prefectureController = TextEditingController(text: _sake.displayData.displayPrefecture);
await showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('酒蔵・都道府県を編集'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: breweryController,
decoration: const InputDecoration(
labelText: '酒蔵',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 16),
TextField(
controller: prefectureController,
decoration: const InputDecoration(
labelText: '都道府県',
border: OutlineInputBorder(),
),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('キャンセル'),
),
ElevatedButton(
onPressed: () async {
final box = Hive.box<SakeItem>('sake_items');
final updated = _sake.copyWith(
brand: breweryController.text,
prefecture: prefectureController.text,
isUserEdited: true,
);
await box.put(_sake.key, updated);
setState(() => _sake = updated);
if (context.mounted) Navigator.pop(context);
},
child: const Text('保存'),
),
],
),
);
}
/// 写真編集モーダルを表示
Future<void> _showPhotoEditModal(BuildContext context) async {
await showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) => SakePhotoEditModal(
sake: _sake,
onUpdated: (updatedSake) {
setState(() => _sake = updatedSake);
},
),
);
}
}