1100 lines
42 KiB
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);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|