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