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

1100 lines
42 KiB
Dart

import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:google_fonts/google_fonts.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 '../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 '../providers/theme_provider.dart';
import 'sake_detail/sections/sake_pricing_section.dart';
import '../theme/app_colors.dart';
import '../constants/app_constants.dart';
import '../widgets/common/munyun_like_button.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 '../services/mbti_compatibility_service.dart';
import '../widgets/sakenowa/sakenowa_detail_recommendation_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>()!;
// Determine confidence text color (CRITICAL FIX: Use AppColors for theme consistency)
// AI Confidence Logic (Theme Aware)
final score = _sake.metadata.aiConfidence ?? 0;
final Color confidenceColor = score >= AppConstants.confidenceScoreHigh
? appColors.brandPrimary // High confidence: Primary brand color
: score >= AppConstants.confidenceScoreMedium
? appColors.textSecondary // Medium confidence: Secondary text color
: appColors.textTertiary; // Low confidence: Tertiary (muted)
// スマートレコメンド (Phase 1-8 Enhanced)
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: [
SliverAppBar(
expandedHeight: 400.0,
floating: false,
pinned: true,
backgroundColor: Theme.of(context).primaryColor,
iconTheme: const IconThemeData(color: Colors.white),
actions: [
MunyunLikeButton(
isLiked: _sake.userData.isFavorite,
onTap: () => _toggleFavorite(),
),
IconButton(
icon: const Icon(LucideIcons.refreshCw),
color: Colors.white,
tooltip: 'AI再解析',
onPressed: () => _reanalyze(context),
),
IconButton(
icon: const Icon(LucideIcons.trash2),
color: Colors.white,
tooltip: '削除',
onPressed: () {
HapticFeedback.heavyImpact();
_showDeleteDialog(context);
},
),
],
flexibleSpace: FlexibleSpaceBar(
background: Stack(
fit: StackFit.expand,
children: [
_sake.displayData.imagePaths.length > 1
? Stack(
fit: StackFit.expand,
children: [
PageView.builder(
itemCount: _sake.displayData.imagePaths.length,
onPageChanged: (index) => setState(() => _currentImageIndex = index),
itemBuilder: (context, index) {
final imageWidget = Image.file(
File(_sake.displayData.imagePaths[index]),
fit: BoxFit.cover,
);
// Apply Hero only to the first image for smooth transition from Grid/List
if (index == 0) {
return Hero(
tag: _sake.id,
child: imageWidget,
);
}
return imageWidget;
},
),
// Simple Indicator
Positioned(
bottom: 16,
right: 16,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.6),
borderRadius: BorderRadius.circular(16),
),
child: Text(
'${_currentImageIndex + 1} / ${_sake.displayData.imagePaths.length}',
style: const TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.bold),
),
),
),
// Photo Edit Button
Positioned(
bottom: 16,
left: 16,
child: FloatingActionButton.small(
heroTag: 'photo_edit',
backgroundColor: Colors.white,
onPressed: () => _showPhotoEditModal(context),
child: Icon(LucideIcons.image, color: appColors.iconDefault),
),
),
],
)
: Stack(
fit: StackFit.expand,
children: [
Hero(
tag: _sake.id,
child: _sake.displayData.imagePaths.isNotEmpty
? Image.file(
File(_sake.displayData.imagePaths.first),
fit: BoxFit.cover,
)
: Container(
color: appColors.surfaceSubtle,
child: Icon(LucideIcons.image, size: 80, color: appColors.iconSubtle),
),
),
// Photo Edit Button for single image
Positioned(
bottom: 16,
left: 16,
child: FloatingActionButton.small(
heroTag: 'photo_edit_single',
backgroundColor: Colors.white,
onPressed: () => _showPhotoEditModal(context),
child: Icon(LucideIcons.image, color: appColors.iconDefault),
),
),
],
),
// Scrim for Header Icons Visibility
Positioned(
top: 0,
left: 0,
right: 0,
height: 100,
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.black.withValues(alpha: 0.7),
Colors.transparent,
],
),
),
),
),
],
),
),
),
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: [
// Confidence Badge
if (_sake.metadata.aiConfidence != null && _sake.itemType != ItemType.set)
Center(
child: Container(
margin: const EdgeInsets.only(bottom: 16),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
decoration: BoxDecoration(
color: confidenceColor.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: confidenceColor.withValues(alpha: 0.5)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(LucideIcons.sparkles, size: 14, color: confidenceColor),
const SizedBox(width: 6),
Text(
'AI確信度: $score%',
style: TextStyle(
color: confidenceColor,
fontWeight: FontWeight.bold,
fontSize: 12,
),
),
],
),
),
),
// MBTI Compatibility Badge (Star Rating Pattern B)
Consumer(
builder: (context, ref, child) {
final userProfile = ref.watch(userProfileProvider);
final mbtiType = userProfile.mbti;
if (mbtiType == null || mbtiType.isEmpty) return const SizedBox.shrink();
final result = MBTICompatibilityService.calculateCompatibility(mbtiType, _sake);
if (!result.hasResult) return const SizedBox.shrink();
final badgeColor = result.starRating >= 4
? appColors.brandPrimary
: result.starRating >= 3
? appColors.textSecondary
: appColors.textTertiary;
return Center(
child: GestureDetector(
onTap: () => _showMbtiCompatibilityDialog(context, result, appColors),
child: Container(
margin: const EdgeInsets.only(bottom: 16),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
decoration: BoxDecoration(
color: badgeColor.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(24),
border: Border.all(color: badgeColor.withValues(alpha: 0.3)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'$mbtiType相性: ',
style: TextStyle(
color: appColors.textSecondary,
fontSize: 12,
),
),
Text(
result.starDisplay,
style: TextStyle(
color: badgeColor,
fontSize: 14,
letterSpacing: 1,
),
),
const SizedBox(width: 4),
Icon(
LucideIcons.info,
size: 12,
color: appColors.iconSubtle,
),
],
),
),
),
);
},
),
// Brand Name
Center(
child: InkWell(
onTap: () => _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);
},
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: Text(
_sake.displayData.displayName,
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold,
letterSpacing: 1.2,
),
textAlign: TextAlign.center,
),
),
const SizedBox(width: 8),
Icon(LucideIcons.pencil, size: 18, color: appColors.iconSubtle),
],
),
),
),
const SizedBox(height: 8),
// Brand / Prefecture
if (_sake.itemType != ItemType.set)
Center(
child: InkWell(
onTap: () => _showBreweryEditDialog(context),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: Text(
'${_sake.displayData.displayBrewery} / ${_sake.displayData.displayPrefecture}',
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: appColors.textSecondary,
fontWeight: FontWeight.w500,
),
),
),
const SizedBox(width: 8),
Icon(LucideIcons.pencil, size: 16, color: appColors.iconSubtle),
],
),
),
),
const SizedBox(height: 16),
// Tags Row
if (_sake.hiddenSpecs.flavorTags.isNotEmpty)
Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: () => _showTagEditDialog(context),
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Wrap(
alignment: WrapAlignment.center,
spacing: 8,
children: _sake.hiddenSpecs.flavorTags.map((tag) => Chip(
label: Text(tag, style: const TextStyle(fontSize: 10)),
visualDensity: VisualDensity.compact,
backgroundColor: Theme.of(context).primaryColor.withValues(alpha: 0.1),
)).toList(),
),
),
),
),
),
const SizedBox(height: 24),
// AI Catchcopy (Mincho)
if (_sake.displayData.catchCopy != null && _sake.itemType != ItemType.set)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Text(
_sake.displayData.catchCopy!,
style: GoogleFonts.zenOldMincho(
fontSize: 24,
height: 1.5,
fontWeight: FontWeight.w500,
color: Theme.of(context).brightness == Brightness.dark
? Colors.white
: Theme.of(context).primaryColor, // Adaptive
),
textAlign: TextAlign.center,
),
),
const SizedBox(height: 24),
const Divider(),
const SizedBox(height: 24),
// Taste Radar Chart (Extracted) with Manual Edit
SakeDetailChart(
sake: _sake,
onTasteStatsEdited: (newStats) => _updateTasteStats(newStats),
),
const SizedBox(height: 24),
const Divider(),
const SizedBox(height: 24),
// 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),
const Divider(),
const SizedBox(height: 16),
// AI Specs Accordion (Extracted)
SakeDetailSpecs(
sake: _sake,
onUpdate: (updatedSake) {
setState(() => _sake = updatedSake);
},
),
const SizedBox(height: 16),
const Divider(),
const SizedBox(height: 16),
// Memo Field (Extracted)
SakeDetailMemo(
initialMemo: _sake.userData.memo,
onUpdate: (value) async {
// Auto-save
final box = Hive.box<SakeItem>('sake_items');
final updated = _sake.copyWith(memo: value, isUserEdited: true);
await box.put(_sake.key, updated);
// Note: setState is needed to update the 'updated' variable locally
// But the text field manages its own state, so we don't strictly need to rebuild the text field
// However, other parts might depend on _sake.userData.memo? Unlikely.
// Actually, we should update _sake here to keep consistency.
setState(() => _sake = updated);
},
),
const SizedBox(height: 48),
// Related Items 3D Carousel (Phase 1-8 Enhanced)
if (_sake.itemType != ItemType.set) ...[ // Hide for Set Items
Row(
children: [
Icon(LucideIcons.sparkles, size: 16, color: Theme.of(context).colorScheme.onSurface),
const SizedBox(width: 8),
Text(
'おすすめの日本酒',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.onSurface,
),
),
],
),
const SizedBox(height: 8),
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 (Phase C3)
SakeMbtiStampSection(sake: _sake),
const SizedBox(height: 24),
],
),
),
),
// Phase 2-3: Business Pricing Section (Extracted)
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;
setState(() => _isAnalyzing = true);
try {
showDialog(
context: context,
barrierDismissible: false,
builder: (context) => const AnalyzingDialog(),
);
final geminiService = GeminiService();
// 既存の画像パスを使用(すでに圧縮済みの想定)
// 注: 既存のデータは未圧縮の可能性があるため、一括圧縮機能で対応
// forceRefresh: true でキャッシュを無視して再解析
final result = await geminiService.analyzeSakeLabel(_sake.displayData.imagePaths, 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 (context.mounted) {
Navigator.pop(context); // Close dialog
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('再解析が完了しました')),
);
}
} catch (e) {
if (context.mounted) {
Navigator.pop(context); // Close dialog (safely pops the top route, hopefully the 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));
});
}
ScaffoldMessenger.of(context).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);
},
),
);
}
}