1566 lines
62 KiB
Dart
1566 lines
62 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,
|
|
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) {
|
|
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.type ?? '-'),
|
|
_buildSpecRow('甘辛度', _sake.hiddenSpecs.sweetnessScore?.toStringAsFixed(1) ?? '-'),
|
|
_buildSpecRow('濃淡度', _sake.hiddenSpecs.bodyScore?.toStringAsFixed(1) ?? '-'),
|
|
const SizedBox(height: 8),
|
|
const Divider(height: 16),
|
|
const SizedBox(height: 8),
|
|
_buildSpecRow('精米歩合', _sake.hiddenSpecs.polishingRatio != null ? '${_sake.hiddenSpecs.polishingRatio}%' : '-'),
|
|
_buildSpecRow('アルコール分', _sake.hiddenSpecs.alcoholContent != null ? '${_sake.hiddenSpecs.alcoholContent}度' : '-'),
|
|
_buildSpecRow('日本酒度', _sake.hiddenSpecs.sakeMeterValue != null ? '${_sake.hiddenSpecs.sakeMeterValue! > 0 ? '+' : ''}${_sake.hiddenSpecs.sakeMeterValue}' : '-'),
|
|
_buildSpecRow('酒米', _sake.hiddenSpecs.riceVariety ?? '-'),
|
|
_buildSpecRow('酵母', _sake.hiddenSpecs.yeast ?? '-'),
|
|
_buildSpecRow('製造年月', _sake.hiddenSpecs.manufacturingYearMonth ?? '-'),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
|
|
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,
|
|
// 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);
|
|
}
|
|
|
|
// 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('写真を更新しました')),
|
|
);
|
|
}
|
|
}
|
|
}
|