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

1551 lines
60 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 'package:image_picker/image_picker.dart';
import 'package:path_provider/path_provider.dart';
import 'package:path/path.dart' as path;
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.dart';
import 'package:hive_flutter/hive_flutter.dart';
import '../providers/sake_list_provider.dart';
import '../widgets/sake_radar_chart.dart';
import '../services/pricing_calculator.dart';
import '../providers/theme_provider.dart';
import '../models/user_profile.dart';
import 'camera_screen.dart';
import '../widgets/common/munyun_like_button.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;
final FocusNode _memoFocusNode = FocusNode(); // Polish: Focus logic
@override
void initState() {
super.initState();
_sake = widget.sake;
_memoFocusNode.addListener(() {
setState(() {}); // Rebuild to hide/show hint
});
}
@override
void dispose() {
_memoFocusNode.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
// Determine confidence text color
final score = _sake.metadata.aiConfidence ?? 0;
final Color confidenceColor = score > 80 ? Colors.green
: score > 50 ? Colors.orange
: Colors.red;
// スマートレコメンド (Phase 1-8 Enhanced)
final allSakeAsync = ref.watch(rawSakeListItemsProvider);
final allSake = allSakeAsync.asData?.value ?? [];
// 新しいレコメンドエンジン使用(五味チャート類似度込み)
final recommendations = SakeRecommendationService.getRecommendations(
target: _sake,
allItems: allSake,
limit: 10,
);
final relatedItems = recommendations.map((rec) => rec.item).toList();
return Scaffold(
body: CustomScrollView(
slivers: [
SliverAppBar(
expandedHeight: 400.0,
floating: false,
pinned: true,
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) {
return Image.file(
File(_sake.displayData.imagePaths[index]),
fit: BoxFit.cover,
);
},
),
// Simple Indicator
Positioned(
bottom: 16,
right: 16,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: Colors.black54,
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: const Icon(LucideIcons.image, color: Colors.black87),
),
),
],
)
: 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: Colors.grey[300],
child: const Icon(LucideIcons.image, size: 80, color: Colors.grey),
),
),
// 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: const Icon(LucideIcons.image, color: Colors.black87),
),
),
],
),
// 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).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,
),
),
],
),
),
),
// Brand Name
Center(
child: InkWell(
onTap: () => _showTextEditDialog(
context,
title: '銘柄名を編集',
initialValue: _sake.displayData.name,
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.name,
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: Colors.grey[600]),
],
),
),
),
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.brewery} / ${_sake.displayData.prefecture}',
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: Theme.of(context).brightness == Brightness.dark
? Colors.grey[400]
: Colors.grey[600],
fontWeight: FontWeight.w500,
),
),
),
const SizedBox(width: 8),
Icon(LucideIcons.pencil, size: 16, color: Colors.grey[500]),
],
),
),
),
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: 32),
const SizedBox(height: 24),
const Divider(),
const SizedBox(height: 24),
// Taste Radar Chart (Phase 1-8)
if (_sake.hiddenSpecs.tasteStats.isNotEmpty && _sake.itemType != ItemType.set)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Column(
children: [
Row(
children: [
Icon(LucideIcons.barChart2, size: 16, color: Theme.of(context).colorScheme.onSurface), // Adaptive Color
const SizedBox(width: 8),
Text(
'Visual Tasting',
style: Theme.of(context).textTheme.labelLarge?.copyWith(
color: Theme.of(context).colorScheme.onSurface, // Adaptive Color
fontWeight: FontWeight.bold,
),
),
],
),
const SizedBox(height: 16),
SizedBox(
height: 200,
child: SakeRadarChart(
tasteStats: _sake.hiddenSpecs.tasteStats,
primaryColor: Theme.of(context).primaryColor,
),
),
],
),
),
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
if (_sake.itemType != ItemType.set)
ExpansionTile(
leading: Icon(LucideIcons.sparkles, color: Theme.of(context).primaryColor),
title: const Text('AIで分析された情報', style: TextStyle(fontWeight: FontWeight.bold)),
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Column(
children: [
_buildSpecRow('甘辛度', _sake.hiddenSpecs.sweetnessScore?.toStringAsFixed(1) ?? '-'),
_buildSpecRow('濃淡度', _sake.hiddenSpecs.bodyScore?.toStringAsFixed(1) ?? '-'),
const SizedBox(height: 8),
Text(
'※ 今後のアップデートで精米歩合、アルコール度数などの詳細スペックを追加予定',
style: TextStyle(color: Colors.grey[600], fontSize: 12),
),
],
),
),
],
),
const SizedBox(height: 16),
const Divider(),
const SizedBox(height: 16),
// Memo Field
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(LucideIcons.fileText, size: 16, color: Theme.of(context).primaryColor),
const SizedBox(width: 8),
Text(
'メモ',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
],
),
const SizedBox(height: 12),
const SizedBox(height: 12),
TextField(
controller: TextEditingController(text: _sake.userData.memo ?? ''),
focusNode: _memoFocusNode,
maxLines: 4,
decoration: InputDecoration(
hintText: _memoFocusNode.hasFocus ? '' : 'メモを入力後に自動保存', // Disappear on focus
hintStyle: TextStyle(color: Colors.grey.withValues(alpha: 0.5)), // Lighter color
border: const OutlineInputBorder(),
filled: true,
fillColor: Theme.of(context).cardColor,
),
onChanged: (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);
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, // Adaptive
),
),
],
),
const SizedBox(height: 8),
Text(
'五味チャート・タグ・酒蔵・産地から自動選出',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).brightness == Brightness.dark
? Colors.grey[400]
: Colors.grey[600],
),
),
const SizedBox(height: 16),
relatedItems.isNotEmpty
? Sake3DCarousel(
items: relatedItems,
height: 220,
)
: Container(
height: 120,
alignment: Alignment.center,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(LucideIcons.info, color: Colors.grey[400], size: 32),
const SizedBox(height: 8),
Text(
'関連する日本酒を追加すると\nおすすめが表示されます',
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey[600], fontSize: 12),
),
],
),
),
const SizedBox(height: 48),
],
// Diagnostic Placeholder (Phase 1-6)
Container(
width: double.infinity,
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
border: Border.all(
color: Theme.of(context).primaryColor.withValues(alpha: 0.3),
style: BorderStyle.solid,
width: 2,
),
borderRadius: BorderRadius.circular(16),
color: Theme.of(context).cardColor.withValues(alpha: 0.5),
),
child: Column(
children: [
Icon(LucideIcons.wand2, color: Theme.of(context).colorScheme.onSurface, size: 32),
const SizedBox(height: 12),
Text(
'診断スタンプ (Coming Soon)',
style: Theme.of(context).textTheme.labelLarge?.copyWith(
color: Theme.of(context).colorScheme.onSurface,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
'MBTI診断との相性がここに表示されます',
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Colors.grey),
),
],
),
),
const SizedBox(height: 24),
],
),
),
),
// Phase 2-3: Business Pricing Section
SliverToBoxAdapter(
child: Consumer(
builder: (context, ref, _) {
final userProfile = ref.watch(userProfileProvider);
if (!userProfile.isBusinessMode) return const SizedBox.shrink();
return _buildPricingSection(context, userProfile);
},
),
),
// End of Pricing Section
// 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 newItem = _sake.copyWith(isFavorite: !_sake.userData.isFavorite);
await box.put(_sake.key, newItem);
setState(() {
_sake = newItem;
});
ScaffoldMessenger.of(context).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();
final result = await geminiService.analyzeSakeLabel(_sake.displayData.imagePaths);
final newItem = _sake.copyWith(
name: result.name ?? _sake.displayData.name,
brand: result.brand ?? _sake.displayData.brewery,
prefecture: result.prefecture ?? _sake.displayData.prefecture,
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,
);
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);
}
// Phase 2-3: Business Pricing UI (Simplified)
Widget _buildPricingSection(BuildContext context, UserProfile userProfile) {
// Calculated Price
final calculatedPrice = PricingCalculator.calculatePrice(_sake);
return Container(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.orange.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.orange.withValues(alpha: 0.3)),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(LucideIcons.coins, color: Colors.orange[800], size: 18),
const SizedBox(width: 6),
Text(
'価格設定',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14, color: Colors.orange[900]),
),
],
),
const SizedBox(height: 4),
Text(
calculatedPrice > 0
? '現在$calculatedPrice円'
: '未設定',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: calculatedPrice > 0 ? Colors.orange[900] : Colors.grey,
),
),
],
),
ElevatedButton(
onPressed: () => _showPriceSettingsDialog(userProfile),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.orange,
foregroundColor: Colors.white,
),
child: const Text('編集'),
),
],
),
);
}
Future<void> _showPriceSettingsDialog(UserProfile argProfile) async {
// final userProfile = ref.read(userProfileProvider); // Now passed as arg
if (!argProfile.isBusinessMode) return;
int? cost = _sake.userData.costPrice;
int? manual = _sake.userData.price;
double markup = _sake.userData.markup;
// Copy existing variants
Map<String, int> variants = Map.from(_sake.userData.priceVariants ?? {});
// Tiny State for Inline Adding
String tempName = '';
String tempPrice = ''; // String to handle empty better
final TextEditingController nameController = TextEditingController();
final TextEditingController priceController = TextEditingController();
await showDialog(
context: context,
builder: (context) => StatefulBuilder(
builder: (context, setModalState) {
final price = PricingCalculator.calculatePrice(
_sake.copyWith(costPrice: cost, manualPrice: manual, markup: markup)
);
void addVariant() {
if (tempName.isNotEmpty && tempPrice.isNotEmpty) {
final parsedPrice = int.tryParse(tempPrice);
if (parsedPrice != null) {
setModalState(() {
variants[tempName] = parsedPrice;
// Clear inputs
tempName = '';
tempPrice = '';
nameController.clear();
priceController.clear();
});
}
}
}
return AlertDialog(
title: const Text('価格設定', style: TextStyle(fontWeight: FontWeight.bold)),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// 1. Manual Price (Top Priority)
TextFormField(
initialValue: manual?.toString() ?? '',
keyboardType: TextInputType.number,
decoration: const InputDecoration(
labelText: '販売価格 (税込)',
hintText: '手動で設定する場合に入力',
suffixText: '',
border: OutlineInputBorder(),
),
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 18),
onChanged: (v) => setModalState(() => manual = int.tryParse(v)),
),
const SizedBox(height: 24),
// 2. Variants (Inline Entry)
const Text('提供サイズ選択', style: TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
// Presets Chips
Wrap(
spacing: 8.0,
runSpacing: 4.0,
children: [
for (var preset in ['グラス (90ml)', '一合 (180ml)', 'ボトル (720ml)'])
ChoiceChip(
label: Text(preset),
selected: tempName == preset,
onSelected: (selected) {
setModalState(() {
// Auto-fill logic
if (selected) {
tempName = preset;
nameController.text = preset;
}
});
},
backgroundColor: Theme.of(context).brightness == Brightness.dark
? Colors.grey[800]
: Colors.grey[200],
selectedColor: Theme.of(context).brightness == Brightness.dark
? Colors.orange.withValues(alpha: 0.5)
: Colors.orange[100],
labelStyle: TextStyle(
color: (tempName == preset)
? (Theme.of(context).brightness == Brightness.dark ? Colors.white : Colors.black)
: (Theme.of(context).brightness == Brightness.dark ? Colors.grey[300] : null),
fontWeight: (tempName == preset) ? FontWeight.bold : null,
),
),
],
),
const SizedBox(height: 12),
// Inline Inputs
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
flex: 3,
child: TextField(
controller: nameController,
decoration: const InputDecoration(
labelText: '名称',
hintText: '例: 徳利',
isDense: true,
border: OutlineInputBorder(),
),
onChanged: (v) => tempName = v,
),
),
const SizedBox(width: 8),
Expanded(
flex: 2,
child: TextField(
controller: priceController, // Using controller to clear
keyboardType: TextInputType.number,
decoration: const InputDecoration(
labelText: '価格',
suffixText: '',
isDense: true,
border: OutlineInputBorder(),
),
onChanged: (v) => tempPrice = v,
),
),
],
),
const SizedBox(height: 8),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
icon: const Icon(LucideIcons.plus),
label: const Text('リストに追加'),
style: ElevatedButton.styleFrom(
backgroundColor: (tempName.isNotEmpty && tempPrice.isNotEmpty) ? Colors.orange : Colors.grey,
foregroundColor: Colors.white,
),
onPressed: (tempName.isNotEmpty && tempPrice.isNotEmpty)
? addVariant
: null,
),
),
const SizedBox(height: 16),
// List of Added Variants
if (variants.isNotEmpty)
Container(
decoration: BoxDecoration(
border: Border.all(color: Colors.grey.withValues(alpha: 0.3)),
borderRadius: BorderRadius.circular(8),
),
child: Column(
children: [
...variants.entries.map((e) => Column(
children: [
ListTile(
dense: true,
title: Text(e.key, style: const TextStyle(fontWeight: FontWeight.w500)),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text('${PricingCalculator.formatPrice(e.value)}', style: const TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(width: 8),
IconButton(
icon: const Icon(LucideIcons.x, color: Colors.grey, size: 18),
onPressed: () {
setModalState(() {
variants.remove(e.key);
});
},
),
],
),
),
if (e.key != variants.keys.last) const Divider(height: 1),
],
)),
],
),
),
const SizedBox(height: 24),
// 3. Auto Calculation (Accordion)
ExpansionTile(
title: const Text('原価・掛率設定 (自動計算)', style: TextStyle(fontSize: 14, color: Colors.grey)),
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 0, vertical: 8),
child: Column(
children: [
TextFormField(
initialValue: cost?.toString() ?? '',
keyboardType: TextInputType.number,
decoration: const InputDecoration(
labelText: '仕入れ値 (円)',
suffixText: '',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.currency_yen),
),
onChanged: (v) => setModalState(() => cost = int.tryParse(v)),
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('掛率: ${markup.toStringAsFixed(1)}'),
TextButton(
child: const Text('リセット'),
onPressed: () => setModalState(() => markup = argProfile.defaultMarkup),
)
],
),
Slider(
value: markup,
min: 1.0,
max: 5.0,
divisions: 40,
label: markup.toStringAsFixed(1),
activeColor: Colors.orange,
onChanged: (v) => setModalState(() => markup = v),
),
Text(
'参考計算価格: ${PricingCalculator.formatPrice(PricingCalculator.roundUpTo50((cost ?? 0) * markup))}円 (50円切上)',
style: const TextStyle(color: Colors.grey, fontSize: 12),
),
],
),
),
],
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('キャンセル'),
),
ElevatedButton(
onPressed: () {
_updatePricing(
costPrice: cost,
manualPrice: manual,
markup: markup,
priceVariants: variants.isEmpty ? null : variants,
);
Navigator.pop(context);
},
child: const Text('保存'),
),
],
);
}
),
);
}
Future<void> _updatePricing({int? costPrice, int? manualPrice, double? markup, Map<String, int>? priceVariants}) async {
final box = Hive.box<SakeItem>('sake_items');
final newItem = _sake.copyWith(
costPrice: costPrice,
manualPrice: manualPrice,
markup: markup ?? _sake.userData.markup,
priceVariants: priceVariants,
isUserEdited: true,
);
await box.put(_sake.key, newItem);
setState(() => _sake = newItem);
}
Future<void> _showDeleteDialog(BuildContext context) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Row(
children: [
Icon(LucideIcons.alertTriangle, color: Colors.orange, size: 24),
const SizedBox(width: 8),
const Text('削除確認'),
],
),
content: Text('${_sake.displayData.name}」を削除しますか?\nこの操作は取り消せません。'),
actions: [
TextButton(
child: const Text('キャンセル'),
onPressed: () => Navigator.pop(context, false),
),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red, // Keeps Red for delete as it is destructive
foregroundColor: Colors.white,
),
child: const Text('削除'),
onPressed: () => Navigator.pop(context, true),
),
],
),
);
if (confirmed == true && mounted) {
final box = Hive.box<SakeItem>('sake_items');
await box.delete(_sake.key);
if (mounted) {
Navigator.pop(context); // Return to previous screen
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('削除しました')),
);
}
}
}
/// スペック行を構築
Widget _buildSpecRow(String label, String value) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(label, style: const TextStyle(fontWeight: FontWeight.w500)),
Text(value, style: TextStyle(color: Colors.grey[700])),
],
),
);
}
/// テキスト編集ダイアログを表示
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('保存'),
),
],
),
);
}
/// 酒蔵・都道府県編集ダイアログを表示
Future<void> _showBreweryEditDialog(BuildContext context) async {
final breweryController = TextEditingController(text: _sake.displayData.brewery);
final prefectureController = TextEditingController(text: _sake.displayData.prefecture);
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) => _PhotoEditModal(
sake: _sake,
onUpdated: (updatedSake) {
setState(() => _sake = updatedSake);
},
),
);
}
}
/// 写真編集モーダルウィジェット
class _PhotoEditModal extends StatefulWidget {
final SakeItem sake;
final Function(SakeItem) onUpdated;
const _PhotoEditModal({
required this.sake,
required this.onUpdated,
});
@override
State<_PhotoEditModal> createState() => _PhotoEditModalState();
}
class _PhotoEditModalState extends State<_PhotoEditModal> {
late List<String> _imagePaths;
@override
void initState() {
super.initState();
_imagePaths = List.from(widget.sake.displayData.imagePaths);
}
@override
Widget build(BuildContext context) {
return Container(
height: MediaQuery.of(context).size.height * 0.7,
decoration: BoxDecoration(
color: Theme.of(context).scaffoldBackgroundColor,
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
),
child: Column(
children: [
// Handle bar
Container(
margin: const EdgeInsets.only(top: 12, bottom: 8),
width: 40,
height: 4,
decoration: BoxDecoration(
color: Colors.grey[300],
borderRadius: BorderRadius.circular(2),
),
),
// Header
Padding(
padding: const EdgeInsets.all(16),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'写真を編集',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
IconButton(
icon: const Icon(LucideIcons.x),
onPressed: () => Navigator.pop(context),
),
],
),
),
const Divider(height: 1),
// Photo grid
Expanded(
child: _imagePaths.isEmpty
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(LucideIcons.image, size: 64, color: Colors.grey[400]),
const SizedBox(height: 16),
Text(
'写真を追加してください',
style: TextStyle(color: Colors.grey[600]),
),
],
),
)
: ReorderableListView.builder(
padding: const EdgeInsets.all(16),
itemCount: _imagePaths.length,
onReorder: (oldIndex, newIndex) {
setState(() {
if (oldIndex < newIndex) {
newIndex -= 1;
}
final item = _imagePaths.removeAt(oldIndex);
_imagePaths.insert(newIndex, item);
});
},
itemBuilder: (context, index) {
final path = _imagePaths[index];
return Card(
key: ValueKey(path),
margin: const EdgeInsets.only(bottom: 12),
child: ListTile(
leading: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.file(
File(path),
width: 60,
height: 60,
fit: BoxFit.cover,
),
),
title: Text(
index == 0 ? 'メイン写真' : '写真 ${index + 1}',
style: const TextStyle(fontWeight: FontWeight.w500),
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(LucideIcons.gripVertical, color: Colors.grey[400]),
const SizedBox(width: 8),
IconButton(
icon: const Icon(LucideIcons.trash2, color: Colors.red),
onPressed: () => _deletePhoto(index),
),
],
),
),
);
},
),
),
// Bottom buttons
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Theme.of(context).cardColor,
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
blurRadius: 8,
offset: const Offset(0, -2),
),
],
),
child: SafeArea(
child: Row(
children: [
Expanded(
child: OutlinedButton.icon(
icon: const Icon(LucideIcons.camera),
label: const Text('写真を追加'),
onPressed: _addPhoto,
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 14),
),
),
),
const SizedBox(width: 12),
Expanded(
child: ElevatedButton(
onPressed: _saveChanges,
style: ElevatedButton.styleFrom(
backgroundColor: Theme.of(context).primaryColor,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 14),
),
child: const Text('保存'),
),
),
],
),
),
),
],
),
);
}
Future<void> _addPhoto() async {
final picker = ImagePicker();
// Show bottom sheet with camera/gallery options
final source = await showModalBottomSheet<ImageSource>(
context: context,
builder: (context) => SafeArea(
child: Wrap(
children: [
ListTile(
leading: const Icon(LucideIcons.camera),
title: const Text('カメラで撮影'),
onTap: () async {
Navigator.pop(context); // Close sheet
// Navigate to CameraScreen in returnPath mode
final result = await Navigator.push(
context,
MaterialPageRoute(builder: (_) => CameraScreen(mode: CameraMode.returnPath)),
);
if (result is String) {
// Add the path
await _saveNewPhoto(result);
}
},
),
ListTile(
leading: const Icon(LucideIcons.image),
title: const Text('ギャラリーから選択'),
onTap: () => Navigator.pop(context, ImageSource.gallery),
),
],
),
),
);
if (source == null) return;
// Handle Gallery (Camera is handled in ListTile callback)
if (source == ImageSource.gallery) {
try {
final XFile? pickedFile = await picker.pickImage(source: source);
if (pickedFile == null) return;
// Save to app directory
final appDir = await getApplicationDocumentsDirectory();
final fileName = '${DateTime.now().millisecondsSinceEpoch}.jpg';
final savedPath = path.join(appDir.path, fileName);
await File(pickedFile.path).copy(savedPath);
await _saveNewPhoto(savedPath);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('エラー: $e')),
);
}
}
}
}
Future<void> _saveNewPhoto(String imagePath) async {
setState(() {
_imagePaths.add(imagePath);
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('写真を追加しました')),
);
}
}
void _deletePhoto(int index) {
setState(() {
_imagePaths.removeAt(index);
});
}
Future<void> _saveChanges() async {
final box = Hive.box<SakeItem>('sake_items');
final updatedSake = widget.sake.copyWith(
imagePaths: _imagePaths,
isUserEdited: true,
);
await box.put(widget.sake.key, updatedSake);
widget.onUpdated(updatedSake);
if (mounted) {
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('写真を更新しました')),
);
}
}
}