fix: address code review findings - crash fixes, proxy OCR accuracy, lint cleanup
- tools/proxy/server.js: add systemInstruction + temperature 0 (fixes brand name hallucination e.g. Tokai->Tokaisou) - gemini_service.dart: add cache read/write to proxy path (was missing, cache was dead code in production) - camera_screen.dart: guard cameras.first crash when no camera available, add mounted checks in gallery loop - sake_detail_screen.dart: remove unused gemini_service import, add ignore comment for showDialog context lint - sake_basic_info_section.dart: remove redundant null-assert operators flagged by dart analyze - dev_menu_screen.dart: remove unused gemini_service import - 6 service files: remove emoji from log strings (project rule compliance, 60+ instances) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
b3e1f5d0a3
commit
69b446ee17
|
|
@ -9,7 +9,6 @@ import 'package:path_provider/path_provider.dart';
|
||||||
import 'package:uuid/uuid.dart';
|
import 'package:uuid/uuid.dart';
|
||||||
import 'package:gal/gal.dart';
|
import 'package:gal/gal.dart';
|
||||||
|
|
||||||
import '../services/gemini_service.dart';
|
|
||||||
import '../providers/gemini_provider.dart';
|
import '../providers/gemini_provider.dart';
|
||||||
import '../services/gemini_exceptions.dart';
|
import '../services/gemini_exceptions.dart';
|
||||||
import '../services/image_compression_service.dart';
|
import '../services/image_compression_service.dart';
|
||||||
|
|
@ -43,6 +42,7 @@ class _CameraScreenState extends ConsumerState<CameraScreen> with SingleTickerPr
|
||||||
Future<void>? _initializeControllerFuture;
|
Future<void>? _initializeControllerFuture;
|
||||||
bool _isTakingPicture = false;
|
bool _isTakingPicture = false;
|
||||||
DateTime? _quotaLockoutTime;
|
DateTime? _quotaLockoutTime;
|
||||||
|
String? _cameraError;
|
||||||
|
|
||||||
double _minZoom = 1.0;
|
double _minZoom = 1.0;
|
||||||
double _maxZoom = 1.0;
|
double _maxZoom = 1.0;
|
||||||
|
|
@ -66,6 +66,12 @@ class _CameraScreenState extends ConsumerState<CameraScreen> with SingleTickerPr
|
||||||
|
|
||||||
Future<void> _initializeCamera() async {
|
Future<void> _initializeCamera() async {
|
||||||
final cameras = await availableCameras();
|
final cameras = await availableCameras();
|
||||||
|
if (cameras.isEmpty) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() => _cameraError = 'カメラが見つかりません。カメラのアクセス権限を確認してください。');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
final firstCamera = cameras.first;
|
final firstCamera = cameras.first;
|
||||||
|
|
||||||
_controller = CameraController(
|
_controller = CameraController(
|
||||||
|
|
@ -288,6 +294,7 @@ class _CameraScreenState extends ConsumerState<CameraScreen> with SingleTickerPr
|
||||||
);
|
);
|
||||||
|
|
||||||
// 3. Add compressed permanent path to capture list
|
// 3. Add compressed permanent path to capture list
|
||||||
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_capturedImages.add(compressedPath);
|
_capturedImages.add(compressedPath);
|
||||||
});
|
});
|
||||||
|
|
@ -296,6 +303,7 @@ class _CameraScreenState extends ConsumerState<CameraScreen> with SingleTickerPr
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('Gallery image compression error: $e');
|
debugPrint('Gallery image compression error: $e');
|
||||||
// Fallback: Use original path (legacy behavior)
|
// Fallback: Use original path (legacy behavior)
|
||||||
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_capturedImages.add(img.path);
|
_capturedImages.add(img.path);
|
||||||
});
|
});
|
||||||
|
|
@ -626,6 +634,23 @@ class _CameraScreenState extends ConsumerState<CameraScreen> with SingleTickerPr
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
if (_cameraError != null) {
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: Colors.black,
|
||||||
|
appBar: AppBar(backgroundColor: Colors.black, foregroundColor: Colors.white),
|
||||||
|
body: Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Text(
|
||||||
|
_cameraError!,
|
||||||
|
style: const TextStyle(color: Colors.white70),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: Colors.black,
|
backgroundColor: Colors.black,
|
||||||
body: FutureBuilder<void>(
|
body: FutureBuilder<void>(
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ import '../services/analysis_cache_service.dart';
|
||||||
import 'package:hive_flutter/hive_flutter.dart';
|
import 'package:hive_flutter/hive_flutter.dart';
|
||||||
import '../models/sake_item.dart';
|
import '../models/sake_item.dart';
|
||||||
import '../providers/sake_list_provider.dart';
|
import '../providers/sake_list_provider.dart';
|
||||||
import '../services/gemini_service.dart';
|
|
||||||
import '../providers/gemini_provider.dart';
|
import '../providers/gemini_provider.dart';
|
||||||
|
|
||||||
class DevMenuScreen extends ConsumerWidget {
|
class DevMenuScreen extends ConsumerWidget {
|
||||||
|
|
|
||||||
|
|
@ -91,11 +91,11 @@ class SakeBasicInfoSection extends ConsumerWidget {
|
||||||
),
|
),
|
||||||
if (showMbti)
|
if (showMbti)
|
||||||
GestureDetector(
|
GestureDetector(
|
||||||
onTap: () => onTapMbtiCompatibility(context, mbtiResult!, appColors),
|
onTap: () => onTapMbtiCompatibility(context, mbtiResult, appColors),
|
||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: badgeColor!.withValues(alpha: 0.1),
|
color: badgeColor.withValues(alpha: 0.1),
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
border: Border.all(color: badgeColor.withValues(alpha: 0.4)),
|
border: Border.all(color: badgeColor.withValues(alpha: 0.4)),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:lucide_icons/lucide_icons.dart';
|
import 'package:lucide_icons/lucide_icons.dart';
|
||||||
import '../models/sake_item.dart';
|
import '../models/sake_item.dart';
|
||||||
import '../services/gemini_service.dart';
|
|
||||||
import '../providers/gemini_provider.dart';
|
import '../providers/gemini_provider.dart';
|
||||||
import '../services/sake_recommendation_service.dart';
|
import '../services/sake_recommendation_service.dart';
|
||||||
import '../widgets/analyzing_dialog.dart';
|
import '../widgets/analyzing_dialog.dart';
|
||||||
|
|
@ -37,30 +36,30 @@ class SakeDetailScreen extends ConsumerStatefulWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
class _SakeDetailScreenState extends ConsumerState<SakeDetailScreen> {
|
class _SakeDetailScreenState extends ConsumerState<SakeDetailScreen> {
|
||||||
// To trigger rebuilds if we don't switch to a stream
|
// To trigger rebuilds if we don't switch to a stream
|
||||||
late SakeItem _sake;
|
late SakeItem _sake;
|
||||||
int _currentImageIndex = 0;
|
int _currentImageIndex = 0;
|
||||||
// Memo logic moved to SakeDetailMemo
|
// Memo logic moved to SakeDetailMemo
|
||||||
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_sake = widget.sake;
|
_sake = widget.sake;
|
||||||
// Memo init removed
|
// Memo init removed
|
||||||
|
|
||||||
// AI分析情報の編集用コントローラーを初期化
|
// AI分析情報の編集用コントローラーを初期化
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
// Memo dispose removed
|
// Memo dispose removed
|
||||||
|
|
||||||
// AI分析情報の編集用コントローラーを破棄
|
// AI分析情報の編集用コントローラーを破棄
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 五味チャートの値を手動更新し、Hiveに永続化
|
/// 五味チャートの値を手動更新し、Hiveに永続化
|
||||||
Future<void> _updateTasteStats(Map<String, int> newStats) async {
|
Future<void> _updateTasteStats(Map<String, int> newStats) async {
|
||||||
final updatedSake = _sake.copyWith(
|
final updatedSake = _sake.copyWith(
|
||||||
tasteStats: newStats,
|
tasteStats: newStats,
|
||||||
|
|
@ -86,11 +85,11 @@ class _SakeDetailScreenState extends ConsumerState<SakeDetailScreen> {
|
||||||
final appColors = Theme.of(context).extension<AppColors>()!;
|
final appColors = Theme.of(context).extension<AppColors>()!;
|
||||||
final isPro = ref.watch(isProProvider);
|
final isPro = ref.watch(isProProvider);
|
||||||
|
|
||||||
// スマートレコメンド
|
// スマートレコメンド
|
||||||
final allSakeAsync = ref.watch(allSakeItemsProvider);
|
final allSakeAsync = ref.watch(allSakeItemsProvider);
|
||||||
final allSake = allSakeAsync.asData?.value ?? [];
|
final allSake = allSakeAsync.asData?.value ?? [];
|
||||||
|
|
||||||
// 新しいレコメンドエンジン使用(五味チャート類似度込み)
|
// 新しいレコメンドエンジン使用(五味チャート類似度込み)
|
||||||
final recommendations = SakeRecommendationService.getRecommendations(
|
final recommendations = SakeRecommendationService.getRecommendations(
|
||||||
target: _sake,
|
target: _sake,
|
||||||
allItems: allSake,
|
allItems: allSake,
|
||||||
|
|
@ -135,7 +134,7 @@ class _SakeDetailScreenState extends ConsumerState<SakeDetailScreen> {
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
// Basic Info: AI確信度・MBTI相性・銘柄名・蔵元・タグ・キャッチコピー (Extracted)
|
// Basic Info: AI確信度・MBTI相性・銘柄名・蔵元・タグ・キャッチコピー (Extracted)
|
||||||
SakeBasicInfoSection(
|
SakeBasicInfoSection(
|
||||||
sake: _sake,
|
sake: _sake,
|
||||||
onTapName: () => _showTextEditDialog(
|
onTapName: () => _showTextEditDialog(
|
||||||
|
|
@ -156,7 +155,7 @@ class _SakeDetailScreenState extends ConsumerState<SakeDetailScreen> {
|
||||||
|
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 32),
|
||||||
|
|
||||||
// Taste Radar Chart
|
// Taste Radar Chart
|
||||||
SakeDetailChart(
|
SakeDetailChart(
|
||||||
sake: _sake,
|
sake: _sake,
|
||||||
onTasteStatsEdited: (newStats) => _updateTasteStats(newStats),
|
onTasteStatsEdited: (newStats) => _updateTasteStats(newStats),
|
||||||
|
|
@ -164,7 +163,7 @@ class _SakeDetailScreenState extends ConsumerState<SakeDetailScreen> {
|
||||||
|
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 32),
|
||||||
|
|
||||||
// Description
|
// Description
|
||||||
if (_sake.hiddenSpecs.description != null && _sake.itemType != ItemType.set)
|
if (_sake.hiddenSpecs.description != null && _sake.itemType != ItemType.set)
|
||||||
Text(
|
Text(
|
||||||
_sake.hiddenSpecs.description!,
|
_sake.hiddenSpecs.description!,
|
||||||
|
|
@ -176,7 +175,7 @@ class _SakeDetailScreenState extends ConsumerState<SakeDetailScreen> {
|
||||||
|
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 32),
|
||||||
|
|
||||||
// AI Specs Accordion
|
// AI Specs Accordion
|
||||||
SakeDetailSpecs(
|
SakeDetailSpecs(
|
||||||
sake: _sake,
|
sake: _sake,
|
||||||
onUpdate: (updatedSake) {
|
onUpdate: (updatedSake) {
|
||||||
|
|
@ -186,7 +185,7 @@ class _SakeDetailScreenState extends ConsumerState<SakeDetailScreen> {
|
||||||
|
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
// Memo Field
|
// Memo Field
|
||||||
SakeDetailMemo(
|
SakeDetailMemo(
|
||||||
initialMemo: _sake.userData.memo,
|
initialMemo: _sake.userData.memo,
|
||||||
onUpdate: (value) async {
|
onUpdate: (value) async {
|
||||||
|
|
@ -199,7 +198,7 @@ class _SakeDetailScreenState extends ConsumerState<SakeDetailScreen> {
|
||||||
|
|
||||||
const SizedBox(height: 48),
|
const SizedBox(height: 48),
|
||||||
|
|
||||||
// Related Items 3D Carousel
|
// Related Items 3D Carousel
|
||||||
if (_sake.itemType != ItemType.set) ...[
|
if (_sake.itemType != ItemType.set) ...[
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
|
|
@ -245,7 +244,7 @@ class _SakeDetailScreenState extends ConsumerState<SakeDetailScreen> {
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
// さけのわ連携おすすめ(未飲銘柄)
|
// さけのわ連携おすすめ(未飲銘柄)
|
||||||
SakenowaDetailRecommendationSection(
|
SakenowaDetailRecommendationSection(
|
||||||
currentSakeName: _sake.displayData.displayName,
|
currentSakeName: _sake.displayData.displayName,
|
||||||
currentTasteData: _sake.hiddenSpecs.activeTasteData,
|
currentTasteData: _sake.hiddenSpecs.activeTasteData,
|
||||||
|
|
@ -255,7 +254,7 @@ class _SakeDetailScreenState extends ConsumerState<SakeDetailScreen> {
|
||||||
const SizedBox(height: 48),
|
const SizedBox(height: 48),
|
||||||
],
|
],
|
||||||
|
|
||||||
// MBTI Diagnostic Stamp Section (Pro only)
|
// MBTI Diagnostic Stamp Section (Pro only)
|
||||||
if (isPro) ...[
|
if (isPro) ...[
|
||||||
SakeMbtiStampSection(sake: _sake),
|
SakeMbtiStampSection(sake: _sake),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
|
|
@ -265,7 +264,7 @@ class _SakeDetailScreenState extends ConsumerState<SakeDetailScreen> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Business Pricing Section
|
// Business Pricing Section
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SakePricingSection(
|
child: SakePricingSection(
|
||||||
sake: _sake,
|
sake: _sake,
|
||||||
|
|
@ -273,7 +272,7 @@ class _SakeDetailScreenState extends ConsumerState<SakeDetailScreen> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Gap with Safe Area
|
// Gap with Safe Area
|
||||||
SliverPadding(
|
SliverPadding(
|
||||||
padding: EdgeInsets.only(bottom: MediaQuery.paddingOf(context).bottom),
|
padding: EdgeInsets.only(bottom: MediaQuery.paddingOf(context).bottom),
|
||||||
),
|
),
|
||||||
|
|
@ -307,10 +306,10 @@ class _SakeDetailScreenState extends ConsumerState<SakeDetailScreen> {
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _reanalyze(BuildContext context) async {
|
Future<void> _reanalyze(BuildContext context) async {
|
||||||
// 1. Check Locks
|
// 1. Check Locks
|
||||||
if (_isAnalyzing) return;
|
if (_isAnalyzing) return;
|
||||||
|
|
||||||
// 2. Check Quota Lockout
|
// 2. Check Quota Lockout
|
||||||
if (_quotaLockoutTime != null) {
|
if (_quotaLockoutTime != null) {
|
||||||
final remaining = _quotaLockoutTime!.difference(DateTime.now());
|
final remaining = _quotaLockoutTime!.difference(DateTime.now());
|
||||||
if (remaining.isNegative) {
|
if (remaining.isNegative) {
|
||||||
|
|
@ -325,7 +324,7 @@ class _SakeDetailScreenState extends ConsumerState<SakeDetailScreen> {
|
||||||
|
|
||||||
if (_sake.displayData.imagePaths.isEmpty) return;
|
if (_sake.displayData.imagePaths.isEmpty) return;
|
||||||
|
|
||||||
// 実ファイルの存在確認(削除済み画像でAPIエラーになるのを防ぐ)
|
// 実ファイルの存在確認(削除済み画像でAPIエラーになるのを防ぐ)
|
||||||
final existingPaths = <String>[];
|
final existingPaths = <String>[];
|
||||||
for (final path in _sake.displayData.imagePaths) {
|
for (final path in _sake.displayData.imagePaths) {
|
||||||
if (await File(path).exists()) {
|
if (await File(path).exists()) {
|
||||||
|
|
@ -346,6 +345,8 @@ class _SakeDetailScreenState extends ConsumerState<SakeDetailScreen> {
|
||||||
setState(() => _isAnalyzing = true);
|
setState(() => _isAnalyzing = true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// ignore: use_build_context_synchronously
|
||||||
|
// mounted チェック済み(334行目)かつ await なしで呼び出すため安全
|
||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
barrierDismissible: false,
|
barrierDismissible: false,
|
||||||
|
|
@ -364,7 +365,7 @@ class _SakeDetailScreenState extends ConsumerState<SakeDetailScreen> {
|
||||||
confidenceScore: result.confidenceScore,
|
confidenceScore: result.confidenceScore,
|
||||||
flavorTags: result.flavorTags.isNotEmpty ? result.flavorTags : _sake.hiddenSpecs.flavorTags,
|
flavorTags: result.flavorTags.isNotEmpty ? result.flavorTags : _sake.hiddenSpecs.flavorTags,
|
||||||
tasteStats: result.tasteStats.isNotEmpty ? result.tasteStats : _sake.hiddenSpecs.tasteStats,
|
tasteStats: result.tasteStats.isNotEmpty ? result.tasteStats : _sake.hiddenSpecs.tasteStats,
|
||||||
// New Fields
|
// New Fields
|
||||||
specificDesignation: result.type ?? _sake.hiddenSpecs.type,
|
specificDesignation: result.type ?? _sake.hiddenSpecs.type,
|
||||||
alcoholContent: result.alcoholContent ?? _sake.hiddenSpecs.alcoholContent,
|
alcoholContent: result.alcoholContent ?? _sake.hiddenSpecs.alcoholContent,
|
||||||
polishingRatio: result.polishingRatio ?? _sake.hiddenSpecs.polishingRatio,
|
polishingRatio: result.polishingRatio ?? _sake.hiddenSpecs.polishingRatio,
|
||||||
|
|
@ -393,7 +394,7 @@ class _SakeDetailScreenState extends ConsumerState<SakeDetailScreen> {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
nav.pop(); // Close dialog
|
nav.pop(); // Close dialog
|
||||||
|
|
||||||
// Check for Quota Error to set Lockout
|
// Check for Quota Error to set Lockout
|
||||||
if (e.toString().contains('Quota') || e.toString().contains('429')) {
|
if (e.toString().contains('Quota') || e.toString().contains('429')) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_quotaLockoutTime = DateTime.now().add(const Duration(minutes: 1));
|
_quotaLockoutTime = DateTime.now().add(const Duration(minutes: 1));
|
||||||
|
|
@ -544,22 +545,22 @@ class _SakeDetailScreenState extends ConsumerState<SakeDetailScreen> {
|
||||||
);
|
);
|
||||||
|
|
||||||
if (confirmed == true && mounted) {
|
if (confirmed == true && mounted) {
|
||||||
// nav/messenger captured above
|
// nav/messenger captured above
|
||||||
|
|
||||||
// Day 5: 画像ファイルを削除(ストレージクリーンアップ)
|
// Day 5: 画像ファイルを削除(ストレージクリーンアップ)
|
||||||
for (final imagePath in _sake.displayData.imagePaths) {
|
for (final imagePath in _sake.displayData.imagePaths) {
|
||||||
try {
|
try {
|
||||||
final imageFile = File(imagePath);
|
final imageFile = File(imagePath);
|
||||||
if (await imageFile.exists()) {
|
if (await imageFile.exists()) {
|
||||||
await imageFile.delete();
|
await imageFile.delete();
|
||||||
debugPrint('🗑️ Deleted image file: $imagePath');
|
debugPrint(' Deleted image file: $imagePath');
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('⚠️ Failed to delete image file: $imagePath - $e');
|
debugPrint(' Failed to delete image file: $imagePath - $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hiveから削除
|
// Hiveから削除
|
||||||
final box = Hive.box<SakeItem>('sake_items');
|
final box = Hive.box<SakeItem>('sake_items');
|
||||||
await box.delete(_sake.key);
|
await box.delete(_sake.key);
|
||||||
|
|
||||||
|
|
@ -572,7 +573,7 @@ class _SakeDetailScreenState extends ConsumerState<SakeDetailScreen> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// テキスト編集ダイアログを表示
|
/// テキスト編集ダイアログを表示
|
||||||
Future<void> _showTextEditDialog(
|
Future<void> _showTextEditDialog(
|
||||||
BuildContext context, {
|
BuildContext context, {
|
||||||
required String title,
|
required String title,
|
||||||
|
|
@ -609,7 +610,7 @@ class _SakeDetailScreenState extends ConsumerState<SakeDetailScreen> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// MBTI相性詳細ダイアログを表示
|
/// MBTI相性詳細ダイアログを表示
|
||||||
void _showMbtiCompatibilityDialog(
|
void _showMbtiCompatibilityDialog(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
CompatibilityResult result,
|
CompatibilityResult result,
|
||||||
|
|
@ -642,7 +643,7 @@ class _SakeDetailScreenState extends ConsumerState<SakeDetailScreen> {
|
||||||
content: Column(
|
content: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
// Star Rating
|
// Star Rating
|
||||||
Text(
|
Text(
|
||||||
result.starDisplay,
|
result.starDisplay,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
|
|
@ -652,7 +653,7 @@ class _SakeDetailScreenState extends ConsumerState<SakeDetailScreen> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
// Percentage & Level
|
// Percentage & Level
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
|
|
@ -683,7 +684,7 @@ class _SakeDetailScreenState extends ConsumerState<SakeDetailScreen> {
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
// Match Reasons
|
// Match Reasons
|
||||||
if (result.reasons.isNotEmpty) ...[
|
if (result.reasons.isNotEmpty) ...[
|
||||||
Align(
|
Align(
|
||||||
alignment: Alignment.centerLeft,
|
alignment: Alignment.centerLeft,
|
||||||
|
|
@ -727,7 +728,7 @@ class _SakeDetailScreenState extends ConsumerState<SakeDetailScreen> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 酒蔵・都道府県編集ダイアログを表示
|
/// 酒蔵・都道府県編集ダイアログを表示
|
||||||
Future<void> _showBreweryEditDialog(BuildContext context) async {
|
Future<void> _showBreweryEditDialog(BuildContext context) async {
|
||||||
final breweryController = TextEditingController(text: _sake.displayData.displayBrewery);
|
final breweryController = TextEditingController(text: _sake.displayData.displayBrewery);
|
||||||
final prefectureController = TextEditingController(text: _sake.displayData.displayPrefecture);
|
final prefectureController = TextEditingController(text: _sake.displayData.displayPrefecture);
|
||||||
|
|
@ -779,7 +780,7 @@ class _SakeDetailScreenState extends ConsumerState<SakeDetailScreen> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 写真編集モーダルを表示
|
/// 写真編集モーダルを表示
|
||||||
Future<void> _showPhotoEditModal(BuildContext context) async {
|
Future<void> _showPhotoEditModal(BuildContext context) async {
|
||||||
await showModalBottomSheet(
|
await showModalBottomSheet(
|
||||||
context: context,
|
context: context,
|
||||||
|
|
|
||||||
|
|
@ -14,256 +14,256 @@ import 'gemini_service.dart';
|
||||||
/// 2. オンライン復帰時: getPendingDrafts() で一覧取得
|
/// 2. オンライン復帰時: getPendingDrafts() で一覧取得
|
||||||
/// 3. analyzeDraft() で個別解析、または analyzeAllDrafts() で一括解析
|
/// 3. analyzeDraft() で個別解析、または analyzeAllDrafts() で一括解析
|
||||||
class DraftService {
|
class DraftService {
|
||||||
static const _uuid = Uuid();
|
static const _uuid = Uuid();
|
||||||
|
|
||||||
/// Draft(解析待ち)アイテムを保存
|
/// Draft(解析待ち)アイテムを保存
|
||||||
///
|
///
|
||||||
/// オフライン時にカメラで撮影した写真を一時保存します。
|
/// オフライン時にカメラで撮影した写真を一時保存します。
|
||||||
/// 写真パスはそのまま保持され、後でAI解析時に使用されます。
|
/// 写真パスはそのまま保持され、後でAI解析時に使用されます。
|
||||||
///
|
///
|
||||||
/// [photoPaths] 保存した写真の絶対パスリスト(ギャラリーに既に保存済みであること)
|
/// [photoPaths] 保存した写真の絶対パスリスト(ギャラリーに既に保存済みであること)
|
||||||
///
|
///
|
||||||
/// Returns: 作成されたDraft SakeItemのkey
|
/// Returns: 作成されたDraft SakeItemのkey
|
||||||
///
|
///
|
||||||
/// Usage:
|
/// Usage:
|
||||||
/// ```dart
|
/// ```dart
|
||||||
/// final draftKey = await DraftService.saveDraft([imagePath1, imagePath2]);
|
/// final draftKey = await DraftService.saveDraft([imagePath1, imagePath2]);
|
||||||
/// ```
|
/// ```
|
||||||
static Future<String> saveDraft(List<String> photoPaths) async {
|
static Future<String> saveDraft(List<String> photoPaths) async {
|
||||||
try {
|
try {
|
||||||
final box = Hive.box<SakeItem>('sake_items');
|
final box = Hive.box<SakeItem>('sake_items');
|
||||||
|
|
||||||
// 🔧 FIX: 最初の画像をdraftPhotoPathに、全画像をimagePathsに保存
|
// FIX: 最初の画像をdraftPhotoPathに、全画像をimagePathsに保存
|
||||||
final firstPhotoPath = photoPaths.isNotEmpty ? photoPaths.first : '';
|
final firstPhotoPath = photoPaths.isNotEmpty ? photoPaths.first : '';
|
||||||
|
|
||||||
// Draft用の仮データを作成
|
// Draft用の仮データを作成
|
||||||
final draftItem = SakeItem(
|
final draftItem = SakeItem(
|
||||||
id: _uuid.v4(),
|
id: _uuid.v4(),
|
||||||
isPendingAnalysis: true,
|
isPendingAnalysis: true,
|
||||||
draftPhotoPath: firstPhotoPath,
|
draftPhotoPath: firstPhotoPath,
|
||||||
displayData: DisplayData(
|
displayData: DisplayData(
|
||||||
name: '解析待ち',
|
name: '解析待ち',
|
||||||
brewery: '---',
|
brewery: '---',
|
||||||
prefecture: '---',
|
prefecture: '---',
|
||||||
imagePaths: photoPaths, // 🔧 FIX: すべての画像を保存
|
imagePaths: photoPaths, // FIX: すべての画像を保存
|
||||||
rating: null,
|
rating: null,
|
||||||
),
|
),
|
||||||
hiddenSpecs: HiddenSpecs(
|
hiddenSpecs: HiddenSpecs(
|
||||||
description: 'オフライン時に撮影された写真です。オンライン復帰後に自動解析されます。',
|
description: 'オフライン時に撮影された写真です。オンライン復帰後に自動解析されます。',
|
||||||
tasteStats: {},
|
tasteStats: {},
|
||||||
flavorTags: [],
|
flavorTags: [],
|
||||||
),
|
),
|
||||||
userData: UserData(
|
userData: UserData(
|
||||||
isFavorite: false,
|
isFavorite: false,
|
||||||
isUserEdited: false,
|
isUserEdited: false,
|
||||||
markup: 3.0,
|
markup: 3.0,
|
||||||
),
|
),
|
||||||
gamification: Gamification(ponPoints: 0),
|
gamification: Gamification(ponPoints: 0),
|
||||||
metadata: Metadata(
|
metadata: Metadata(
|
||||||
createdAt: DateTime.now(),
|
createdAt: DateTime.now(),
|
||||||
aiConfidence: null,
|
aiConfidence: null,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
await box.add(draftItem);
|
await box.add(draftItem);
|
||||||
debugPrint('Draft saved: ${draftItem.id}');
|
debugPrint('Draft saved: ${draftItem.id}');
|
||||||
return draftItem.id;
|
return draftItem.id;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('Draft save error: $e');
|
debugPrint('Draft save error: $e');
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// すべての解析待ちDraftを取得
|
/// すべての解析待ちDraftを取得
|
||||||
///
|
///
|
||||||
/// Returns: 解析待ちアイテムのリスト
|
/// Returns: 解析待ちアイテムのリスト
|
||||||
///
|
///
|
||||||
/// Usage:
|
/// Usage:
|
||||||
/// ```dart
|
/// ```dart
|
||||||
/// final drafts = await DraftService.getPendingDrafts();
|
/// final drafts = await DraftService.getPendingDrafts();
|
||||||
/// print('未解析: ${drafts.length}件');
|
/// print('未解析: ${drafts.length}件');
|
||||||
/// ```
|
/// ```
|
||||||
static Future<List<SakeItem>> getPendingDrafts() async {
|
static Future<List<SakeItem>> getPendingDrafts() async {
|
||||||
try {
|
try {
|
||||||
final box = Hive.box<SakeItem>('sake_items');
|
final box = Hive.box<SakeItem>('sake_items');
|
||||||
final allItems = box.values.toList();
|
final allItems = box.values.toList();
|
||||||
final pendingDrafts = allItems.where((item) => item.isPendingAnalysis).toList();
|
final pendingDrafts = allItems.where((item) => item.isPendingAnalysis).toList();
|
||||||
|
|
||||||
debugPrint('Pending drafts: ${pendingDrafts.length}');
|
debugPrint('Pending drafts: ${pendingDrafts.length}');
|
||||||
return pendingDrafts;
|
return pendingDrafts;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('Get pending drafts error: $e');
|
debugPrint('Get pending drafts error: $e');
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 解析待ちDraftの件数を取得
|
/// 解析待ちDraftの件数を取得
|
||||||
///
|
///
|
||||||
/// Returns: 解析待ちアイテム数
|
/// Returns: 解析待ちアイテム数
|
||||||
static Future<int> getPendingCount() async {
|
static Future<int> getPendingCount() async {
|
||||||
final drafts = await getPendingDrafts();
|
final drafts = await getPendingDrafts();
|
||||||
return drafts.length;
|
return drafts.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 特定のDraftを解析し、正式なSakeItemに変換
|
/// 特定のDraftを解析し、正式なSakeItemに変換
|
||||||
///
|
///
|
||||||
/// [itemKey] DraftアイテムのHive key
|
/// [itemKey] DraftアイテムのHive key
|
||||||
///
|
///
|
||||||
/// Returns: 解析結果のSakeAnalysisResult
|
/// Returns: 解析結果のSakeAnalysisResult
|
||||||
///
|
///
|
||||||
/// Throws: AI解析エラー、ネットワークエラーなど
|
/// Throws: AI解析エラー、ネットワークエラーなど
|
||||||
///
|
///
|
||||||
/// Usage:
|
/// Usage:
|
||||||
/// ```dart
|
/// ```dart
|
||||||
/// try {
|
/// try {
|
||||||
/// final result = await DraftService.analyzeDraft(itemKey);
|
/// final result = await DraftService.analyzeDraft(itemKey);
|
||||||
/// print('解析完了: ${result.name}');
|
/// print('解析完了: ${result.name}');
|
||||||
/// } catch (e) {
|
/// } catch (e) {
|
||||||
/// print('解析失敗: $e');
|
/// print('解析失敗: $e');
|
||||||
/// }
|
/// }
|
||||||
/// ```
|
/// ```
|
||||||
static Future<SakeAnalysisResult> analyzeDraft(
|
static Future<SakeAnalysisResult> analyzeDraft(
|
||||||
dynamic itemKey, {
|
dynamic itemKey, {
|
||||||
required GeminiService geminiService,
|
required GeminiService geminiService,
|
||||||
}) async {
|
}) async {
|
||||||
final box = Hive.box<SakeItem>('sake_items');
|
final box = Hive.box<SakeItem>('sake_items');
|
||||||
final item = box.get(itemKey);
|
final item = box.get(itemKey);
|
||||||
|
|
||||||
if (item == null) {
|
if (item == null) {
|
||||||
throw Exception('Draft item not found: $itemKey');
|
throw Exception('Draft item not found: $itemKey');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!item.isPendingAnalysis) {
|
if (!item.isPendingAnalysis) {
|
||||||
throw Exception('Item is not a draft: $itemKey');
|
throw Exception('Item is not a draft: $itemKey');
|
||||||
}
|
}
|
||||||
|
|
||||||
final photoPath = item.draftPhotoPath;
|
final photoPath = item.draftPhotoPath;
|
||||||
if (photoPath == null || photoPath.isEmpty) {
|
if (photoPath == null || photoPath.isEmpty) {
|
||||||
throw Exception('Draft has no photo path: $itemKey');
|
throw Exception('Draft has no photo path: $itemKey');
|
||||||
}
|
}
|
||||||
|
|
||||||
debugPrint('Analyzing draft: $itemKey (${item.displayData.displayName})');
|
debugPrint('Analyzing draft: $itemKey (${item.displayData.displayName})');
|
||||||
|
|
||||||
final imagePaths = item.displayData.imagePaths;
|
final imagePaths = item.displayData.imagePaths;
|
||||||
final pathsToAnalyze = imagePaths.isNotEmpty ? imagePaths : [photoPath];
|
final pathsToAnalyze = imagePaths.isNotEmpty ? imagePaths : [photoPath];
|
||||||
|
|
||||||
final result = await geminiService.analyzeSakeLabel(pathsToAnalyze);
|
final result = await geminiService.analyzeSakeLabel(pathsToAnalyze);
|
||||||
|
|
||||||
debugPrint('✅ Analysis completed: ${result.name}');
|
debugPrint(' Analysis completed: ${result.name}');
|
||||||
|
|
||||||
// Draftを正式なアイテムに更新
|
// Draftを正式なアイテムに更新
|
||||||
final updatedItem = item.copyWith(
|
final updatedItem = item.copyWith(
|
||||||
name: result.name,
|
name: result.name,
|
||||||
brand: result.brand,
|
brand: result.brand,
|
||||||
prefecture: result.prefecture,
|
prefecture: result.prefecture,
|
||||||
description: result.description,
|
description: result.description,
|
||||||
catchCopy: result.catchCopy,
|
catchCopy: result.catchCopy,
|
||||||
flavorTags: result.flavorTags,
|
flavorTags: result.flavorTags,
|
||||||
tasteStats: result.tasteStats,
|
tasteStats: result.tasteStats,
|
||||||
confidenceScore: result.confidenceScore,
|
confidenceScore: result.confidenceScore,
|
||||||
// 新規フィールド
|
// 新規フィールド
|
||||||
specificDesignation: result.type,
|
specificDesignation: result.type,
|
||||||
alcoholContent: result.alcoholContent,
|
alcoholContent: result.alcoholContent,
|
||||||
polishingRatio: result.polishingRatio,
|
polishingRatio: result.polishingRatio,
|
||||||
sakeMeterValue: result.sakeMeterValue,
|
sakeMeterValue: result.sakeMeterValue,
|
||||||
riceVariety: result.riceVariety,
|
riceVariety: result.riceVariety,
|
||||||
yeast: result.yeast,
|
yeast: result.yeast,
|
||||||
manufacturingYearMonth: result.manufacturingYearMonth,
|
manufacturingYearMonth: result.manufacturingYearMonth,
|
||||||
// Draft状態を解除
|
// Draft状態を解除
|
||||||
isPendingAnalysis: false,
|
isPendingAnalysis: false,
|
||||||
draftPhotoPath: null,
|
draftPhotoPath: null,
|
||||||
);
|
);
|
||||||
|
|
||||||
// await updatedItem.save(); // Error: This object is currently not in a box.
|
// await updatedItem.save(); // Error: This object is currently not in a box.
|
||||||
await box.put(itemKey, updatedItem);
|
await box.put(itemKey, updatedItem);
|
||||||
debugPrint('💾 Draft updated to normal item: $itemKey');
|
debugPrint(' Draft updated to normal item: $itemKey');
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// すべての解析待ちDraftを一括解析
|
/// すべての解析待ちDraftを一括解析
|
||||||
///
|
///
|
||||||
/// Returns: {成功件数, 失敗件数, エラーメッセージリスト}
|
/// Returns: {成功件数, 失敗件数, エラーメッセージリスト}
|
||||||
///
|
///
|
||||||
/// Usage:
|
/// Usage:
|
||||||
/// ```dart
|
/// ```dart
|
||||||
/// final result = await DraftService.analyzeAllDrafts((progress, total) {
|
/// final result = await DraftService.analyzeAllDrafts((progress, total) {
|
||||||
/// print('進捗: $progress / $total');
|
/// print('進捗: $progress / $total');
|
||||||
/// });
|
/// });
|
||||||
/// print('成功: ${result['success']}, 失敗: ${result['failed']}');
|
/// print('成功: ${result['success']}, 失敗: ${result['failed']}');
|
||||||
/// ```
|
/// ```
|
||||||
static Future<Map<String, dynamic>> analyzeAllDrafts({
|
static Future<Map<String, dynamic>> analyzeAllDrafts({
|
||||||
required GeminiService geminiService,
|
required GeminiService geminiService,
|
||||||
Function(int progress, int total)? onProgress,
|
Function(int progress, int total)? onProgress,
|
||||||
}) async {
|
}) async {
|
||||||
final drafts = await getPendingDrafts();
|
final drafts = await getPendingDrafts();
|
||||||
final total = drafts.length;
|
final total = drafts.length;
|
||||||
|
|
||||||
if (total == 0) {
|
if (total == 0) {
|
||||||
debugPrint('No pending drafts to analyze');
|
debugPrint('No pending drafts to analyze');
|
||||||
return {'success': 0, 'failed': 0, 'errors': []};
|
return {'success': 0, 'failed': 0, 'errors': []};
|
||||||
}
|
}
|
||||||
|
|
||||||
int successCount = 0;
|
int successCount = 0;
|
||||||
int failedCount = 0;
|
int failedCount = 0;
|
||||||
final List<String> errors = [];
|
final List<String> errors = [];
|
||||||
|
|
||||||
for (int i = 0; i < total; i++) {
|
for (int i = 0; i < total; i++) {
|
||||||
final draft = drafts[i];
|
final draft = drafts[i];
|
||||||
final itemKey = draft.key;
|
final itemKey = draft.key;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
onProgress?.call(i + 1, total);
|
onProgress?.call(i + 1, total);
|
||||||
await analyzeDraft(itemKey, geminiService: geminiService);
|
await analyzeDraft(itemKey, geminiService: geminiService);
|
||||||
successCount++;
|
successCount++;
|
||||||
debugPrint('[${i+1}/$total] Success: ${draft.displayData.displayName}');
|
debugPrint('[${i+1}/$total] Success: ${draft.displayData.displayName}');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
failedCount++;
|
failedCount++;
|
||||||
final errorMsg = '[${i+1}/$total] ${draft.displayData.displayName}: $e';
|
final errorMsg = '[${i+1}/$total] ${draft.displayData.displayName}: $e';
|
||||||
errors.add(errorMsg);
|
errors.add(errorMsg);
|
||||||
debugPrint(errorMsg);
|
debugPrint(errorMsg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
debugPrint('Batch analysis completed: $successCount success, $failedCount failed');
|
debugPrint('Batch analysis completed: $successCount success, $failedCount failed');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'success': successCount,
|
'success': successCount,
|
||||||
'failed': failedCount,
|
'failed': failedCount,
|
||||||
'errors': errors,
|
'errors': errors,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Draft を削除(解析を諦める場合)
|
/// Draft を削除(解析を諦める場合)
|
||||||
///
|
///
|
||||||
/// [itemKey] DraftアイテムのHive key
|
/// [itemKey] DraftアイテムのHive key
|
||||||
///
|
///
|
||||||
/// Usage:
|
/// Usage:
|
||||||
/// ```dart
|
/// ```dart
|
||||||
/// await DraftService.deleteDraft(itemKey);
|
/// await DraftService.deleteDraft(itemKey);
|
||||||
/// ```
|
/// ```
|
||||||
static Future<void> deleteDraft(dynamic itemKey) async {
|
static Future<void> deleteDraft(dynamic itemKey) async {
|
||||||
final box = Hive.box<SakeItem>('sake_items');
|
final box = Hive.box<SakeItem>('sake_items');
|
||||||
final item = box.get(itemKey);
|
final item = box.get(itemKey);
|
||||||
|
|
||||||
if (item != null && item.isPendingAnalysis) {
|
if (item != null && item.isPendingAnalysis) {
|
||||||
await box.delete(itemKey);
|
await box.delete(itemKey);
|
||||||
debugPrint('Draft deleted: $itemKey');
|
debugPrint('Draft deleted: $itemKey');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// すべてのDraftを削除
|
/// すべてのDraftを削除
|
||||||
///
|
///
|
||||||
/// Returns: 削除件数
|
/// Returns: 削除件数
|
||||||
static Future<int> deleteAllDrafts() async {
|
static Future<int> deleteAllDrafts() async {
|
||||||
final drafts = await getPendingDrafts();
|
final drafts = await getPendingDrafts();
|
||||||
final count = drafts.length;
|
final count = drafts.length;
|
||||||
|
|
||||||
for (final draft in drafts) {
|
for (final draft in drafts) {
|
||||||
await deleteDraft(draft.key);
|
await deleteDraft(draft.key);
|
||||||
}
|
}
|
||||||
|
|
||||||
debugPrint('All drafts deleted: $count');
|
debugPrint('All drafts deleted: $count');
|
||||||
return count;
|
return count;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -76,8 +76,18 @@ class GeminiService {
|
||||||
return _callDirectApi(imagePaths, customPrompt, forceRefresh: forceRefresh);
|
return _callDirectApi(imagePaths, customPrompt, forceRefresh: forceRefresh);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 1. キャッシュチェック(forceRefresh=false のときのみ)
|
||||||
|
if (!forceRefresh && imagePaths.isNotEmpty) {
|
||||||
|
final imageHash = await AnalysisCacheService.computeCombinedHash(imagePaths);
|
||||||
|
final cached = await AnalysisCacheService.getCached(imageHash);
|
||||||
|
if (cached != null) {
|
||||||
|
debugPrint('Proxy cache hit: skipping API call');
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 1. レート制限 (クライアント側連打防止)
|
// 2. レート制限 (クライアント側連打防止)
|
||||||
if (_lastApiCallTime != null) {
|
if (_lastApiCallTime != null) {
|
||||||
final elapsed = DateTime.now().difference(_lastApiCallTime!);
|
final elapsed = DateTime.now().difference(_lastApiCallTime!);
|
||||||
if (elapsed < _minApiInterval) {
|
if (elapsed < _minApiInterval) {
|
||||||
|
|
@ -150,11 +160,20 @@ class GeminiService {
|
||||||
|
|
||||||
if (missing.isNotEmpty) {
|
if (missing.isNotEmpty) {
|
||||||
debugPrint('WARNING: AI response missing keys: $missing. Old schema?');
|
debugPrint('WARNING: AI response missing keys: $missing. Old schema?');
|
||||||
// We could throw here, but for now let's just log.
|
|
||||||
// In strict mode, we might want to fail the analysis to force retry.
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// キャッシュに保存(次回同一画像はAPI不使用)
|
||||||
|
if (imagePaths.isNotEmpty) {
|
||||||
|
final imageHash = await AnalysisCacheService.computeCombinedHash(imagePaths);
|
||||||
|
await AnalysisCacheService.saveCache(imageHash, result);
|
||||||
|
await AnalysisCacheService.registerBrandIndex(
|
||||||
|
result.name,
|
||||||
|
imageHash,
|
||||||
|
forceUpdate: forceRefresh,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
} else {
|
} else {
|
||||||
// Proxy側での論理エラー (レート制限超過など)
|
// Proxy側での論理エラー (レート制限超過など)
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import 'package:path_provider/path_provider.dart';
|
||||||
import '../models/sake_item.dart';
|
import '../models/sake_item.dart';
|
||||||
import 'image_compression_service.dart';
|
import 'image_compression_service.dart';
|
||||||
|
|
||||||
// ⚠️ Critical Fix (Day 5.5): cleanupTempFiles() を修正
|
// Critical Fix (Day 5.5): cleanupTempFiles() を修正
|
||||||
// 問題: getApplicationDocumentsDirectory() をスキャンして _compressed, _gallery を削除
|
// 問題: getApplicationDocumentsDirectory() をスキャンして _compressed, _gallery を削除
|
||||||
// 結果: 本物の画像を誤削除
|
// 結果: 本物の画像を誤削除
|
||||||
// 修正: getTemporaryDirectory() のみをスキャン
|
// 修正: getTemporaryDirectory() のみをスキャン
|
||||||
|
|
@ -14,15 +14,15 @@ import 'image_compression_service.dart';
|
||||||
///
|
///
|
||||||
/// 用途: アプリ更新後、既存の未圧縮画像を圧縮してストレージを削減
|
/// 用途: アプリ更新後、既存の未圧縮画像を圧縮してストレージを削減
|
||||||
class ImageBatchCompressionService {
|
class ImageBatchCompressionService {
|
||||||
/// 既存の画像を一括圧縮
|
/// 既存の画像を一括圧縮
|
||||||
///
|
///
|
||||||
/// 処理内容:
|
/// 処理内容:
|
||||||
/// 1. すべての SakeItem から画像パスを取得
|
/// 1. すべての SakeItem から画像パスを取得
|
||||||
/// 2. 各画像を圧縮(1024px, JPEG 85%)
|
/// 2. 各画像を圧縮(1024px, JPEG 85%)
|
||||||
/// 3. 元画像を削除
|
/// 3. 元画像を削除
|
||||||
/// 4. SakeItem の imagePaths を更新
|
/// 4. SakeItem の imagePaths を更新
|
||||||
///
|
///
|
||||||
/// 戻り値: (圧縮成功数, 圧縮失敗数, 削減したバイト数)
|
/// 戻り値: (圧縮成功数, 圧縮失敗数, 削減したバイト数)
|
||||||
static Future<(int, int, int)> compressAllImages({
|
static Future<(int, int, int)> compressAllImages({
|
||||||
required Function(int current, int total, String fileName) onProgress,
|
required Function(int current, int total, String fileName) onProgress,
|
||||||
}) async {
|
}) async {
|
||||||
|
|
@ -34,7 +34,7 @@ class ImageBatchCompressionService {
|
||||||
int savedBytes = 0;
|
int savedBytes = 0;
|
||||||
int totalImages = 0;
|
int totalImages = 0;
|
||||||
|
|
||||||
// 全画像数をカウント
|
// 全画像数をカウント
|
||||||
for (final item in allItems) {
|
for (final item in allItems) {
|
||||||
totalImages += item.displayData.imagePaths.length;
|
totalImages += item.displayData.imagePaths.length;
|
||||||
}
|
}
|
||||||
|
|
@ -50,62 +50,62 @@ class ImageBatchCompressionService {
|
||||||
final file = File(originalPath);
|
final file = File(originalPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// ファイルが存在するか確認
|
// ファイルが存在するか確認
|
||||||
if (!await file.exists()) {
|
if (!await file.exists()) {
|
||||||
debugPrint('⚠️ File not found: $originalPath');
|
debugPrint(' File not found: $originalPath');
|
||||||
newPaths.add(originalPath); // パスをそのまま保持
|
newPaths.add(originalPath); // パスをそのまま保持
|
||||||
failedCount++;
|
failedCount++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 元のファイルサイズを取得
|
// 元のファイルサイズを取得
|
||||||
final originalSize = await file.length();
|
final originalSize = await file.length();
|
||||||
|
|
||||||
// ファイル名から拡張子を取得
|
// ファイル名から拡張子を取得
|
||||||
final fileName = originalPath.split('/').last;
|
final fileName = originalPath.split('/').last;
|
||||||
onProgress(processedCount, totalImages, fileName);
|
onProgress(processedCount, totalImages, fileName);
|
||||||
|
|
||||||
// 既に圧縮済みか確認(ファイルサイズで判断)
|
// 既に圧縮済みか確認(ファイルサイズで判断)
|
||||||
if (originalSize < 500 * 1024) { // 500KB以下なら既に圧縮済み
|
if (originalSize < 500 * 1024) { // 500KB以下なら既に圧縮済み
|
||||||
debugPrint('✅ Already compressed: $fileName (${(originalSize / 1024).toStringAsFixed(1)}KB)');
|
debugPrint(' Already compressed: $fileName (${(originalSize / 1024).toStringAsFixed(1)}KB)');
|
||||||
newPaths.add(originalPath);
|
newPaths.add(originalPath);
|
||||||
successCount++;
|
successCount++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Day 5: 安全な圧縮(一時ファイル経由)
|
// Day 5: 安全な圧縮(一時ファイル経由)
|
||||||
debugPrint('🗜️ Compressing: $fileName (${(originalSize / 1024 / 1024).toStringAsFixed(1)}MB)');
|
debugPrint(' Compressing: $fileName (${(originalSize / 1024 / 1024).toStringAsFixed(1)}MB)');
|
||||||
|
|
||||||
// 1. 一時ファイルに圧縮(targetPathを指定しない)
|
// 1. 一時ファイルに圧縮(targetPathを指定しない)
|
||||||
final tempCompressedPath = await ImageCompressionService.compressForGemini(originalPath);
|
final tempCompressedPath = await ImageCompressionService.compressForGemini(originalPath);
|
||||||
|
|
||||||
// 2. 圧縮後のサイズを取得
|
// 2. 圧縮後のサイズを取得
|
||||||
final compressedSize = await File(tempCompressedPath).length();
|
final compressedSize = await File(tempCompressedPath).length();
|
||||||
final saved = originalSize - compressedSize;
|
final saved = originalSize - compressedSize;
|
||||||
savedBytes += saved;
|
savedBytes += saved;
|
||||||
|
|
||||||
debugPrint('✅ Compressed: $fileName - ${(originalSize / 1024).toStringAsFixed(1)}KB → ${(compressedSize / 1024).toStringAsFixed(1)}KB (${(saved / 1024).toStringAsFixed(1)}KB saved)');
|
debugPrint(' Compressed: $fileName - ${(originalSize / 1024).toStringAsFixed(1)}KB → ${(compressedSize / 1024).toStringAsFixed(1)}KB (${(saved / 1024).toStringAsFixed(1)}KB saved)');
|
||||||
|
|
||||||
// 3. 圧縮成功後に元ファイルを削除
|
// 3. 圧縮成功後に元ファイルを削除
|
||||||
try {
|
try {
|
||||||
await file.delete();
|
await file.delete();
|
||||||
debugPrint('🗑️ Deleted original: $originalPath');
|
debugPrint(' Deleted original: $originalPath');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('⚠️ Failed to delete original: $e');
|
debugPrint(' Failed to delete original: $e');
|
||||||
// エラー時は一時ファイルを削除して元のパスを保持
|
// エラー時は一時ファイルを削除して元のパスを保持
|
||||||
await File(tempCompressedPath).delete();
|
await File(tempCompressedPath).delete();
|
||||||
newPaths.add(originalPath);
|
newPaths.add(originalPath);
|
||||||
failedCount++;
|
failedCount++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. 一時ファイルを元の場所に移動
|
// 4. 一時ファイルを元の場所に移動
|
||||||
try {
|
try {
|
||||||
await File(tempCompressedPath).rename(originalPath);
|
await File(tempCompressedPath).rename(originalPath);
|
||||||
debugPrint('📦 Moved compressed file to: $originalPath');
|
debugPrint(' Moved compressed file to: $originalPath');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('⚠️ Failed to rename file: $e');
|
debugPrint(' Failed to rename file: $e');
|
||||||
// エラー時は一時ファイルをそのまま使用
|
// エラー時は一時ファイルをそのまま使用
|
||||||
newPaths.add(tempCompressedPath);
|
newPaths.add(tempCompressedPath);
|
||||||
failedCount++;
|
failedCount++;
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -115,13 +115,13 @@ class ImageBatchCompressionService {
|
||||||
successCount++;
|
successCount++;
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('❌ Failed to compress: $originalPath - $e');
|
debugPrint(' Failed to compress: $originalPath - $e');
|
||||||
newPaths.add(originalPath); // エラー時は元のパスを保持
|
newPaths.add(originalPath); // エラー時は元のパスを保持
|
||||||
failedCount++;
|
failedCount++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SakeItem の imagePaths を更新
|
// SakeItem の imagePaths を更新
|
||||||
if (newPaths.isNotEmpty) {
|
if (newPaths.isNotEmpty) {
|
||||||
final updatedItem = item.copyWith(
|
final updatedItem = item.copyWith(
|
||||||
imagePaths: newPaths,
|
imagePaths: newPaths,
|
||||||
|
|
@ -133,9 +133,9 @@ class ImageBatchCompressionService {
|
||||||
return (successCount, failedCount, savedBytes);
|
return (successCount, failedCount, savedBytes);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// ストレージ使用量を取得
|
/// ストレージ使用量を取得
|
||||||
///
|
///
|
||||||
/// 戻り値: (総ファイル数, 総バイト数)
|
/// 戻り値: (総ファイル数, 総バイト数)
|
||||||
static Future<(int, int)> getStorageUsage() async {
|
static Future<(int, int)> getStorageUsage() async {
|
||||||
final box = Hive.box<SakeItem>('sake_items');
|
final box = Hive.box<SakeItem>('sake_items');
|
||||||
final allItems = box.values.toList();
|
final allItems = box.values.toList();
|
||||||
|
|
@ -156,45 +156,45 @@ class ImageBatchCompressionService {
|
||||||
return (totalFiles, totalBytes);
|
return (totalFiles, totalBytes);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 一時ファイルをクリーンアップ
|
/// 一時ファイルをクリーンアップ
|
||||||
///
|
///
|
||||||
/// 🔒 Safe: getTemporaryDirectory() のみをスキャン(永続ファイルは削除しない)
|
/// Safe: getTemporaryDirectory() のみをスキャン(永続ファイルは削除しない)
|
||||||
///
|
///
|
||||||
/// 戻り値: (削除したファイル数, 削減したバイト数)
|
/// 戻り値: (削除したファイル数, 削減したバイト数)
|
||||||
static Future<(int, int)> cleanupTempFiles() async {
|
static Future<(int, int)> cleanupTempFiles() async {
|
||||||
try {
|
try {
|
||||||
// ⚠️ 重要: getTemporaryDirectory() を使用(getApplicationDocumentsDirectory() ではない)
|
// 重要: getTemporaryDirectory() を使用(getApplicationDocumentsDirectory() ではない)
|
||||||
final directory = await getTemporaryDirectory();
|
final directory = await getTemporaryDirectory();
|
||||||
final dir = Directory(directory.path);
|
final dir = Directory(directory.path);
|
||||||
|
|
||||||
int deletedCount = 0;
|
int deletedCount = 0;
|
||||||
int deletedBytes = 0;
|
int deletedBytes = 0;
|
||||||
|
|
||||||
// ディレクトリ内のすべてのファイルをスキャン
|
// ディレクトリ内のすべてのファイルをスキャン
|
||||||
await for (final entity in dir.list()) {
|
await for (final entity in dir.list()) {
|
||||||
if (entity is File) {
|
if (entity is File) {
|
||||||
final fileName = entity.path.split('/').last;
|
final fileName = entity.path.split('/').last;
|
||||||
|
|
||||||
// 一時ファイルを検出(画像ファイルのみ)
|
// 一時ファイルを検出(画像ファイルのみ)
|
||||||
if (fileName.endsWith('.jpg') || fileName.endsWith('.jpeg') || fileName.endsWith('.png')) {
|
if (fileName.endsWith('.jpg') || fileName.endsWith('.jpeg') || fileName.endsWith('.png')) {
|
||||||
try {
|
try {
|
||||||
final fileSize = await entity.length();
|
final fileSize = await entity.length();
|
||||||
await entity.delete();
|
await entity.delete();
|
||||||
deletedCount++;
|
deletedCount++;
|
||||||
deletedBytes += fileSize;
|
deletedBytes += fileSize;
|
||||||
debugPrint('🗑️ Deleted temp file: $fileName (${(fileSize / 1024).toStringAsFixed(1)}KB)');
|
debugPrint(' Deleted temp file: $fileName (${(fileSize / 1024).toStringAsFixed(1)}KB)');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('⚠️ Failed to delete temp file: $fileName - $e');
|
debugPrint(' Failed to delete temp file: $fileName - $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
debugPrint('✅ Cleanup complete: $deletedCount files, ${(deletedBytes / 1024 / 1024).toStringAsFixed(1)}MB');
|
debugPrint(' Cleanup complete: $deletedCount files, ${(deletedBytes / 1024 / 1024).toStringAsFixed(1)}MB');
|
||||||
|
|
||||||
return (deletedCount, deletedBytes);
|
return (deletedCount, deletedBytes);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('❌ Cleanup error: $e');
|
debugPrint(' Cleanup error: $e');
|
||||||
return (0, 0);
|
return (0, 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,217 +9,217 @@ import '../models/sake_item.dart';
|
||||||
///
|
///
|
||||||
/// バックアップ復元後に画像パスが不整合になった場合の修復
|
/// バックアップ復元後に画像パスが不整合になった場合の修復
|
||||||
class ImagePathRepairService {
|
class ImagePathRepairService {
|
||||||
/// 画像パスの整合性をチェック
|
/// 画像パスの整合性をチェック
|
||||||
///
|
///
|
||||||
/// 戻り値: (総アイテム数, 問題のあるアイテム数, 欠損ファイル数)
|
/// 戻り値: (総アイテム数, 問題のあるアイテム数, 欠損ファイル数)
|
||||||
static Future<(int, int, int)> diagnose() async {
|
static Future<(int, int, int)> diagnose() async {
|
||||||
try {
|
try {
|
||||||
final box = Hive.box<SakeItem>('sake_items');
|
final box = Hive.box<SakeItem>('sake_items');
|
||||||
final items = box.values.toList();
|
final items = box.values.toList();
|
||||||
|
|
||||||
int totalItems = items.length;
|
int totalItems = items.length;
|
||||||
int problematicItems = 0;
|
int problematicItems = 0;
|
||||||
int missingFiles = 0;
|
int missingFiles = 0;
|
||||||
|
|
||||||
debugPrint('🔍 画像パス診断開始: $totalItems アイテム');
|
debugPrint(' 画像パス診断開始: $totalItems アイテム');
|
||||||
|
|
||||||
for (final item in items) {
|
for (final item in items) {
|
||||||
bool hasIssue = false;
|
bool hasIssue = false;
|
||||||
|
|
||||||
for (final imagePath in item.displayData.imagePaths) {
|
for (final imagePath in item.displayData.imagePaths) {
|
||||||
final file = File(imagePath);
|
final file = File(imagePath);
|
||||||
if (!await file.exists()) {
|
if (!await file.exists()) {
|
||||||
debugPrint('❌ Missing: $imagePath (${item.displayData.displayName})');
|
debugPrint(' Missing: $imagePath (${item.displayData.displayName})');
|
||||||
missingFiles++;
|
missingFiles++;
|
||||||
hasIssue = true;
|
hasIssue = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasIssue) {
|
if (hasIssue) {
|
||||||
problematicItems++;
|
problematicItems++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
debugPrint('📊 診断結果: $totalItems アイテム中 $problematicItems に問題あり ($missingFiles ファイル欠損)');
|
debugPrint(' 診断結果: $totalItems アイテム中 $problematicItems に問題あり ($missingFiles ファイル欠損)');
|
||||||
|
|
||||||
return (totalItems, problematicItems, missingFiles);
|
return (totalItems, problematicItems, missingFiles);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('❌ 診断エラー: $e');
|
debugPrint(' 診断エラー: $e');
|
||||||
return (0, 0, 0);
|
return (0, 0, 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 画像パスを修復
|
/// 画像パスを修復
|
||||||
///
|
///
|
||||||
/// 戦略:
|
/// 戦略:
|
||||||
/// 1. 存在しないパスを検出
|
/// 1. 存在しないパスを検出
|
||||||
/// 2. getApplicationDocumentsDirectory() 内の実際のファイルを探す
|
/// 2. getApplicationDocumentsDirectory() 内の実際のファイルを探す
|
||||||
/// 3. ファイル名(UUID)で照合
|
/// 3. ファイル名(UUID)で照合
|
||||||
/// 4. パスを更新
|
/// 4. パスを更新
|
||||||
///
|
///
|
||||||
/// 戻り値: (修復したアイテム数, 修復した画像パス数)
|
/// 戻り値: (修復したアイテム数, 修復した画像パス数)
|
||||||
static Future<(int, int)> repair() async {
|
static Future<(int, int)> repair() async {
|
||||||
try {
|
try {
|
||||||
final box = Hive.box<SakeItem>('sake_items');
|
final box = Hive.box<SakeItem>('sake_items');
|
||||||
final items = box.values.toList();
|
final items = box.values.toList();
|
||||||
final appDir = await getApplicationDocumentsDirectory();
|
final appDir = await getApplicationDocumentsDirectory();
|
||||||
|
|
||||||
// アプリディレクトリ内のすべての画像ファイルを取得
|
// アプリディレクトリ内のすべての画像ファイルを取得
|
||||||
final availableFiles = <String>[];
|
final availableFiles = <String>[];
|
||||||
final dir = Directory(appDir.path);
|
final dir = Directory(appDir.path);
|
||||||
await for (final entity in dir.list()) {
|
await for (final entity in dir.list()) {
|
||||||
if (entity is File) {
|
if (entity is File) {
|
||||||
final fileName = path.basename(entity.path);
|
final fileName = path.basename(entity.path);
|
||||||
if (fileName.endsWith('.jpg') || fileName.endsWith('.jpeg') || fileName.endsWith('.png')) {
|
if (fileName.endsWith('.jpg') || fileName.endsWith('.jpeg') || fileName.endsWith('.png')) {
|
||||||
availableFiles.add(entity.path);
|
availableFiles.add(entity.path);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
debugPrint('📁 利用可能な画像ファイル: ${availableFiles.length}個');
|
debugPrint(' 利用可能な画像ファイル: ${availableFiles.length}個');
|
||||||
|
|
||||||
int repairedItems = 0;
|
int repairedItems = 0;
|
||||||
int repairedPaths = 0;
|
int repairedPaths = 0;
|
||||||
|
|
||||||
for (final item in items) {
|
for (final item in items) {
|
||||||
bool needsRepair = false;
|
bool needsRepair = false;
|
||||||
List<String> newPaths = [];
|
List<String> newPaths = [];
|
||||||
|
|
||||||
for (final oldPath in item.displayData.imagePaths) {
|
for (final oldPath in item.displayData.imagePaths) {
|
||||||
final file = File(oldPath);
|
final file = File(oldPath);
|
||||||
|
|
||||||
if (await file.exists()) {
|
if (await file.exists()) {
|
||||||
// パスが有効な場合はそのまま
|
// パスが有効な場合はそのまま
|
||||||
newPaths.add(oldPath);
|
newPaths.add(oldPath);
|
||||||
} else {
|
} else {
|
||||||
// パスが無効な場合、ファイル名で照合
|
// パスが無効な場合、ファイル名で照合
|
||||||
final oldFileName = path.basename(oldPath);
|
final oldFileName = path.basename(oldPath);
|
||||||
|
|
||||||
// 完全一致を探す
|
// 完全一致を探す
|
||||||
String? matchedPath;
|
String? matchedPath;
|
||||||
for (final availablePath in availableFiles) {
|
for (final availablePath in availableFiles) {
|
||||||
if (path.basename(availablePath) == oldFileName) {
|
if (path.basename(availablePath) == oldFileName) {
|
||||||
matchedPath = availablePath;
|
matchedPath = availablePath;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (matchedPath != null) {
|
if (matchedPath != null) {
|
||||||
newPaths.add(matchedPath);
|
newPaths.add(matchedPath);
|
||||||
repairedPaths++;
|
repairedPaths++;
|
||||||
needsRepair = true;
|
needsRepair = true;
|
||||||
debugPrint('🔧 Repaired: $oldFileName -> $matchedPath');
|
debugPrint(' Repaired: $oldFileName -> $matchedPath');
|
||||||
} else {
|
} else {
|
||||||
// マッチしない場合、警告してスキップ
|
// マッチしない場合、警告してスキップ
|
||||||
debugPrint('⚠️ No match for: $oldFileName (${item.displayData.displayName})');
|
debugPrint(' No match for: $oldFileName (${item.displayData.displayName})');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (needsRepair && newPaths.isNotEmpty) {
|
if (needsRepair && newPaths.isNotEmpty) {
|
||||||
// パスを更新
|
// パスを更新
|
||||||
final updatedItem = item.copyWith(
|
final updatedItem = item.copyWith(
|
||||||
imagePaths: newPaths,
|
imagePaths: newPaths,
|
||||||
);
|
);
|
||||||
await box.put(item.key, updatedItem); // 🔧 Fixed: updatedItem を保存
|
await box.put(item.key, updatedItem); // Fixed: updatedItem を保存
|
||||||
repairedItems++;
|
repairedItems++;
|
||||||
debugPrint('✅ Updated: ${item.displayData.displayName} (${newPaths.length} paths)');
|
debugPrint(' Updated: ${item.displayData.displayName} (${newPaths.length} paths)');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
debugPrint('✅ 修復完了: $repairedItems アイテム、$repairedPaths パス');
|
debugPrint(' 修復完了: $repairedItems アイテム、$repairedPaths パス');
|
||||||
|
|
||||||
return (repairedItems, repairedPaths);
|
return (repairedItems, repairedPaths);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('❌ 修復エラー: $e');
|
debugPrint(' 修復エラー: $e');
|
||||||
return (0, 0);
|
return (0, 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 孤立したファイルを検出(Hiveに参照されていない画像ファイル)
|
/// 孤立したファイルを検出(Hiveに参照されていない画像ファイル)
|
||||||
///
|
///
|
||||||
/// 戻り値: (孤立ファイル数, 合計サイズ(bytes))
|
/// 戻り値: (孤立ファイル数, 合計サイズ(bytes))
|
||||||
static Future<(int, int)> findOrphanedFiles() async {
|
static Future<(int, int)> findOrphanedFiles() async {
|
||||||
try {
|
try {
|
||||||
final box = Hive.box<SakeItem>('sake_items');
|
final box = Hive.box<SakeItem>('sake_items');
|
||||||
final items = box.values.toList();
|
final items = box.values.toList();
|
||||||
final appDir = await getApplicationDocumentsDirectory();
|
final appDir = await getApplicationDocumentsDirectory();
|
||||||
|
|
||||||
// Hiveに登録されているすべての画像パスを収集
|
// Hiveに登録されているすべての画像パスを収集
|
||||||
final registeredPaths = <String>{};
|
final registeredPaths = <String>{};
|
||||||
for (final item in items) {
|
for (final item in items) {
|
||||||
registeredPaths.addAll(item.displayData.imagePaths);
|
registeredPaths.addAll(item.displayData.imagePaths);
|
||||||
}
|
}
|
||||||
|
|
||||||
// アプリディレクトリ内のすべての画像ファイルを取得
|
// アプリディレクトリ内のすべての画像ファイルを取得
|
||||||
int orphanedCount = 0;
|
int orphanedCount = 0;
|
||||||
int totalSize = 0;
|
int totalSize = 0;
|
||||||
|
|
||||||
final dir = Directory(appDir.path);
|
final dir = Directory(appDir.path);
|
||||||
await for (final entity in dir.list()) {
|
await for (final entity in dir.list()) {
|
||||||
if (entity is File) {
|
if (entity is File) {
|
||||||
final fileName = path.basename(entity.path);
|
final fileName = path.basename(entity.path);
|
||||||
if (fileName.endsWith('.jpg') || fileName.endsWith('.jpeg') || fileName.endsWith('.png')) {
|
if (fileName.endsWith('.jpg') || fileName.endsWith('.jpeg') || fileName.endsWith('.png')) {
|
||||||
if (!registeredPaths.contains(entity.path)) {
|
if (!registeredPaths.contains(entity.path)) {
|
||||||
final size = await entity.length();
|
final size = await entity.length();
|
||||||
orphanedCount++;
|
orphanedCount++;
|
||||||
totalSize += size;
|
totalSize += size;
|
||||||
debugPrint('🗑️ Orphaned: $fileName (${(size / 1024).toStringAsFixed(1)}KB)');
|
debugPrint(' Orphaned: $fileName (${(size / 1024).toStringAsFixed(1)}KB)');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
debugPrint('📊 孤立ファイル: $orphanedCount 個 (${(totalSize / 1024 / 1024).toStringAsFixed(1)}MB)');
|
debugPrint(' 孤立ファイル: $orphanedCount 個 (${(totalSize / 1024 / 1024).toStringAsFixed(1)}MB)');
|
||||||
|
|
||||||
return (orphanedCount, totalSize);
|
return (orphanedCount, totalSize);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('❌ 孤立ファイル検出エラー: $e');
|
debugPrint(' 孤立ファイル検出エラー: $e');
|
||||||
return (0, 0);
|
return (0, 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 孤立ファイルを削除
|
/// 孤立ファイルを削除
|
||||||
///
|
///
|
||||||
/// 戻り値: (削除したファイル数, 削減したサイズ(bytes))
|
/// 戻り値: (削除したファイル数, 削減したサイズ(bytes))
|
||||||
static Future<(int, int)> cleanOrphanedFiles() async {
|
static Future<(int, int)> cleanOrphanedFiles() async {
|
||||||
try {
|
try {
|
||||||
final box = Hive.box<SakeItem>('sake_items');
|
final box = Hive.box<SakeItem>('sake_items');
|
||||||
final items = box.values.toList();
|
final items = box.values.toList();
|
||||||
final appDir = await getApplicationDocumentsDirectory();
|
final appDir = await getApplicationDocumentsDirectory();
|
||||||
|
|
||||||
// Hiveに登録されているすべての画像パスを収集
|
// Hiveに登録されているすべての画像パスを収集
|
||||||
final registeredPaths = <String>{};
|
final registeredPaths = <String>{};
|
||||||
for (final item in items) {
|
for (final item in items) {
|
||||||
registeredPaths.addAll(item.displayData.imagePaths);
|
registeredPaths.addAll(item.displayData.imagePaths);
|
||||||
}
|
}
|
||||||
|
|
||||||
// アプリディレクトリ内のすべての画像ファイルを取得
|
// アプリディレクトリ内のすべての画像ファイルを取得
|
||||||
int deletedCount = 0;
|
int deletedCount = 0;
|
||||||
int deletedSize = 0;
|
int deletedSize = 0;
|
||||||
|
|
||||||
final dir = Directory(appDir.path);
|
final dir = Directory(appDir.path);
|
||||||
await for (final entity in dir.list()) {
|
await for (final entity in dir.list()) {
|
||||||
if (entity is File) {
|
if (entity is File) {
|
||||||
final fileName = path.basename(entity.path);
|
final fileName = path.basename(entity.path);
|
||||||
if (fileName.endsWith('.jpg') || fileName.endsWith('.jpeg') || fileName.endsWith('.png')) {
|
if (fileName.endsWith('.jpg') || fileName.endsWith('.jpeg') || fileName.endsWith('.png')) {
|
||||||
if (!registeredPaths.contains(entity.path)) {
|
if (!registeredPaths.contains(entity.path)) {
|
||||||
final size = await entity.length();
|
final size = await entity.length();
|
||||||
await entity.delete();
|
await entity.delete();
|
||||||
deletedCount++;
|
deletedCount++;
|
||||||
deletedSize += size;
|
deletedSize += size;
|
||||||
debugPrint('🗑️ Deleted: $fileName (${(size / 1024).toStringAsFixed(1)}KB)');
|
debugPrint(' Deleted: $fileName (${(size / 1024).toStringAsFixed(1)}KB)');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
debugPrint('✅ 孤立ファイル削除完了: $deletedCount 個 (${(deletedSize / 1024 / 1024).toStringAsFixed(1)}MB)');
|
debugPrint(' 孤立ファイル削除完了: $deletedCount 個 (${(deletedSize / 1024 / 1024).toStringAsFixed(1)}MB)');
|
||||||
|
|
||||||
return (deletedCount, deletedSize);
|
return (deletedCount, deletedSize);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('❌ 孤立ファイル削除エラー: $e');
|
debugPrint(' 孤立ファイル削除エラー: $e');
|
||||||
return (0, 0);
|
return (0, 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -34,35 +34,35 @@ class SakenowaAutoMatchingService {
|
||||||
|
|
||||||
SakenowaAutoMatchingService(this._sakenowaService);
|
SakenowaAutoMatchingService(this._sakenowaService);
|
||||||
|
|
||||||
/// 日本酒をさけのわデータベースとマッチング
|
/// 日本酒をさけのわデータベースとマッチング
|
||||||
///
|
///
|
||||||
/// [sakeItem]: マッチング対象の日本酒
|
/// [sakeItem]: マッチング対象の日本酒
|
||||||
/// [minScore]: 最小類似度スコア(デフォルト: 0.7)
|
/// [minScore]: 最小類似度スコア(デフォルト: 0.7)
|
||||||
/// [autoApply]: マッチング結果を自動で適用(デフォルト: false)
|
/// [autoApply]: マッチング結果を自動で適用(デフォルト: false)
|
||||||
///
|
///
|
||||||
/// Returns: マッチング結果(見つからない場合はscoreが0)
|
/// Returns: マッチング結果(見つからない場合はscoreが0)
|
||||||
Future<MatchResult> matchSake({
|
Future<MatchResult> matchSake({
|
||||||
required SakeItem sakeItem,
|
required SakeItem sakeItem,
|
||||||
double minScore = 0.7,
|
double minScore = 0.7,
|
||||||
bool autoApply = false,
|
bool autoApply = false,
|
||||||
}) async {
|
}) async {
|
||||||
try {
|
try {
|
||||||
debugPrint('🔍 [SakenowaAutoMatching] 開始: ${sakeItem.displayData.displayName}');
|
debugPrint(' [SakenowaAutoMatching] 開始: ${sakeItem.displayData.displayName}');
|
||||||
|
|
||||||
// さけのわデータ取得
|
// さけのわデータ取得
|
||||||
final brands = await _sakenowaService.getBrands();
|
final brands = await _sakenowaService.getBrands();
|
||||||
final breweries = await _sakenowaService.getBreweries();
|
final breweries = await _sakenowaService.getBreweries();
|
||||||
final areas = await _sakenowaService.getAreas();
|
final areas = await _sakenowaService.getAreas();
|
||||||
final flavorCharts = await _sakenowaService.getFlavorCharts();
|
final flavorCharts = await _sakenowaService.getFlavorCharts();
|
||||||
|
|
||||||
debugPrint('📊 [SakenowaAutoMatching] データ取得完了: ${brands.length} brands');
|
debugPrint(' [SakenowaAutoMatching] データ取得完了: ${brands.length} brands');
|
||||||
|
|
||||||
// マップ化
|
// マップ化
|
||||||
final breweryMap = {for (var b in breweries) b.id: b};
|
final breweryMap = {for (var b in breweries) b.id: b};
|
||||||
final areaMap = {for (var a in areas) a.id: a};
|
final areaMap = {for (var a in areas) a.id: a};
|
||||||
final chartMap = {for (var c in flavorCharts) c.brandId: c};
|
final chartMap = {for (var c in flavorCharts) c.brandId: c};
|
||||||
|
|
||||||
// 最良マッチを探す
|
// 最良マッチを探す
|
||||||
SakenowaBrand? bestBrand;
|
SakenowaBrand? bestBrand;
|
||||||
double bestScore = 0.0;
|
double bestScore = 0.0;
|
||||||
|
|
||||||
|
|
@ -78,24 +78,24 @@ class SakenowaAutoMatchingService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 最小スコア未満なら失敗
|
// 最小スコア未満なら失敗
|
||||||
if (bestScore < minScore) {
|
if (bestScore < minScore) {
|
||||||
debugPrint('❌ [SakenowaAutoMatching] スコア不足: $bestScore < $minScore');
|
debugPrint(' [SakenowaAutoMatching] スコア不足: $bestScore < $minScore');
|
||||||
return MatchResult(
|
return MatchResult(
|
||||||
score: bestScore,
|
score: bestScore,
|
||||||
isConfident: false,
|
isConfident: false,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// マッチング成功
|
// マッチング成功
|
||||||
final brewery = bestBrand != null ? breweryMap[bestBrand.breweryId] : null;
|
final brewery = bestBrand != null ? breweryMap[bestBrand.breweryId] : null;
|
||||||
final area = brewery != null ? areaMap[brewery.areaId] : null;
|
final area = brewery != null ? areaMap[brewery.areaId] : null;
|
||||||
final chart = bestBrand != null ? chartMap[bestBrand.id] : null;
|
final chart = bestBrand != null ? chartMap[bestBrand.id] : null;
|
||||||
|
|
||||||
debugPrint('✅ [SakenowaAutoMatching] マッチング成功!');
|
debugPrint(' [SakenowaAutoMatching] マッチング成功!');
|
||||||
debugPrint(' 銘柄: ${bestBrand?.name} (スコア: $bestScore)');
|
debugPrint(' 銘柄: ${bestBrand?.name} (スコア: $bestScore)');
|
||||||
debugPrint(' 酒蔵: ${brewery?.name}');
|
debugPrint(' 酒蔵: ${brewery?.name}');
|
||||||
debugPrint(' 地域: ${area?.name}');
|
debugPrint(' 地域: ${area?.name}');
|
||||||
|
|
||||||
final result = MatchResult(
|
final result = MatchResult(
|
||||||
brand: bestBrand,
|
brand: bestBrand,
|
||||||
|
|
@ -106,15 +106,15 @@ class SakenowaAutoMatchingService {
|
||||||
isConfident: bestScore >= 0.8,
|
isConfident: bestScore >= 0.8,
|
||||||
);
|
);
|
||||||
|
|
||||||
// 自動適用
|
// 自動適用
|
||||||
if (autoApply && result.hasMatch) {
|
if (autoApply && result.hasMatch) {
|
||||||
await applyMatch(sakeItem, result);
|
await applyMatch(sakeItem, result);
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
} catch (e, stackTrace) {
|
} catch (e, stackTrace) {
|
||||||
debugPrint('💥 [SakenowaAutoMatching] エラー: $e');
|
debugPrint(' [SakenowaAutoMatching] エラー: $e');
|
||||||
debugPrint('Stack trace: $stackTrace');
|
debugPrint('Stack trace: $stackTrace');
|
||||||
return MatchResult(
|
return MatchResult(
|
||||||
score: 0.0,
|
score: 0.0,
|
||||||
isConfident: false,
|
isConfident: false,
|
||||||
|
|
@ -122,26 +122,26 @@ class SakenowaAutoMatchingService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// マッチング結果をSakeItemに適用
|
/// マッチング結果をSakeItemに適用
|
||||||
///
|
///
|
||||||
/// DisplayDataのsakenowaフィールドとHiddenSpecsを更新
|
/// DisplayDataのsakenowaフィールドとHiddenSpecsを更新
|
||||||
Future<void> applyMatch(SakeItem sakeItem, MatchResult result) async {
|
Future<void> applyMatch(SakeItem sakeItem, MatchResult result) async {
|
||||||
if (!result.hasMatch) {
|
if (!result.hasMatch) {
|
||||||
debugPrint('⚠️ [SakenowaAutoMatching] マッチなし、適用スキップ');
|
debugPrint(' [SakenowaAutoMatching] マッチなし、適用スキップ');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
debugPrint('💾 [SakenowaAutoMatching] マッチング結果を適用中...');
|
debugPrint(' [SakenowaAutoMatching] マッチング結果を適用中...');
|
||||||
|
|
||||||
// DisplayData更新(さけのわ統一名称)
|
// DisplayData更新(さけのわ統一名称)
|
||||||
final updatedDisplayData = sakeItem.displayData.copyWith(
|
final updatedDisplayData = sakeItem.displayData.copyWith(
|
||||||
sakenowaName: result.brand?.name,
|
sakenowaName: result.brand?.name,
|
||||||
sakenowaBrewery: result.brewery?.name,
|
sakenowaBrewery: result.brewery?.name,
|
||||||
sakenowaPrefecture: result.area?.name,
|
sakenowaPrefecture: result.area?.name,
|
||||||
);
|
);
|
||||||
|
|
||||||
// HiddenSpecs更新(6軸フレーバーチャート)
|
// HiddenSpecs更新(6軸フレーバーチャート)
|
||||||
Map<String, double>? flavorChartMap;
|
Map<String, double>? flavorChartMap;
|
||||||
if (result.flavorChart != null) {
|
if (result.flavorChart != null) {
|
||||||
flavorChartMap = {
|
flavorChartMap = {
|
||||||
|
|
@ -159,36 +159,36 @@ class SakenowaAutoMatchingService {
|
||||||
sakenowaFlavorChart: flavorChartMap,
|
sakenowaFlavorChart: flavorChartMap,
|
||||||
);
|
);
|
||||||
|
|
||||||
// SakeItem更新
|
// SakeItem更新
|
||||||
sakeItem.displayData = updatedDisplayData;
|
sakeItem.displayData = updatedDisplayData;
|
||||||
sakeItem.hiddenSpecs = updatedHiddenSpecs;
|
sakeItem.hiddenSpecs = updatedHiddenSpecs;
|
||||||
|
|
||||||
// Hiveに保存
|
// Hiveに保存
|
||||||
await sakeItem.save();
|
await sakeItem.save();
|
||||||
|
|
||||||
debugPrint('✅ [SakenowaAutoMatching] 適用完了!');
|
debugPrint(' [SakenowaAutoMatching] 適用完了!');
|
||||||
debugPrint(' displayName: ${sakeItem.displayData.displayName}');
|
debugPrint(' displayName: ${sakeItem.displayData.displayName}');
|
||||||
debugPrint(' displayBrewery: ${sakeItem.displayData.displayBrewery}');
|
debugPrint(' displayBrewery: ${sakeItem.displayData.displayBrewery}');
|
||||||
debugPrint(' displayPrefecture: ${sakeItem.displayData.displayPrefecture}');
|
debugPrint(' displayPrefecture: ${sakeItem.displayData.displayPrefecture}');
|
||||||
} catch (e, stackTrace) {
|
} catch (e, stackTrace) {
|
||||||
debugPrint('💥 [SakenowaAutoMatching] 適用エラー: $e');
|
debugPrint(' [SakenowaAutoMatching] 適用エラー: $e');
|
||||||
debugPrint('Stack trace: $stackTrace');
|
debugPrint('Stack trace: $stackTrace');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 複数の日本酒を一括マッチング
|
/// 複数の日本酒を一括マッチング
|
||||||
///
|
///
|
||||||
/// [sakeItems]: マッチング対象のリスト
|
/// [sakeItems]: マッチング対象のリスト
|
||||||
/// [minScore]: 最小類似度スコア
|
/// [minScore]: 最小類似度スコア
|
||||||
/// [autoApply]: マッチング結果を自動で適用
|
/// [autoApply]: マッチング結果を自動で適用
|
||||||
///
|
///
|
||||||
/// Returns: マッチング成功数
|
/// Returns: マッチング成功数
|
||||||
Future<int> matchBatch({
|
Future<int> matchBatch({
|
||||||
required List<SakeItem> sakeItems,
|
required List<SakeItem> sakeItems,
|
||||||
double minScore = 0.7,
|
double minScore = 0.7,
|
||||||
bool autoApply = false,
|
bool autoApply = false,
|
||||||
}) async {
|
}) async {
|
||||||
debugPrint('🔄 [SakenowaAutoMatching] バッチ処理開始: ${sakeItems.length} 件');
|
debugPrint(' [SakenowaAutoMatching] バッチ処理開始: ${sakeItems.length} 件');
|
||||||
|
|
||||||
int successCount = 0;
|
int successCount = 0;
|
||||||
|
|
||||||
|
|
@ -204,16 +204,16 @@ class SakenowaAutoMatchingService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
debugPrint('✅ [SakenowaAutoMatching] バッチ処理完了: $successCount/${sakeItems.length} 成功');
|
debugPrint(' [SakenowaAutoMatching] バッチ処理完了: $successCount/${sakeItems.length} 成功');
|
||||||
|
|
||||||
return successCount;
|
return successCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 既存データのさけのわフィールドをクリア
|
/// 既存データのさけのわフィールドをクリア
|
||||||
///
|
///
|
||||||
/// デバッグ・テスト用
|
/// デバッグ・テスト用
|
||||||
Future<void> clearSakenowaData(SakeItem sakeItem) async {
|
Future<void> clearSakenowaData(SakeItem sakeItem) async {
|
||||||
debugPrint('🧹 [SakenowaAutoMatching] さけのわデータクリア: ${sakeItem.displayData.displayName}');
|
debugPrint(' [SakenowaAutoMatching] さけのわデータクリア: ${sakeItem.displayData.displayName}');
|
||||||
|
|
||||||
final clearedDisplayData = sakeItem.displayData.copyWith(
|
final clearedDisplayData = sakeItem.displayData.copyWith(
|
||||||
sakenowaName: null,
|
sakenowaName: null,
|
||||||
|
|
@ -231,6 +231,6 @@ class SakenowaAutoMatchingService {
|
||||||
|
|
||||||
await sakeItem.save();
|
await sakeItem.save();
|
||||||
|
|
||||||
debugPrint('✅ [SakenowaAutoMatching] クリア完了');
|
debugPrint(' [SakenowaAutoMatching] クリア完了');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ class ShukoDiagnosisService {
|
||||||
return ShukoProfile.empty();
|
return ShukoProfile.empty();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Calculate Average Stats (only from items with valid data)
|
// 1. Calculate Average Stats (only from items with valid data)
|
||||||
double totalAroma = 0;
|
double totalAroma = 0;
|
||||||
double totalBitterness = 0;
|
double totalBitterness = 0;
|
||||||
double totalSweetness = 0;
|
double totalSweetness = 0;
|
||||||
|
|
@ -19,15 +19,15 @@ class ShukoDiagnosisService {
|
||||||
double totalBody = 0;
|
double totalBody = 0;
|
||||||
int count = 0;
|
int count = 0;
|
||||||
|
|
||||||
debugPrint('🍶🍶🍶 SHUKO DIAGNOSIS START: Total items = ${items.length}');
|
debugPrint(' SHUKO DIAGNOSIS START: Total items = ${items.length}');
|
||||||
|
|
||||||
for (var item in items) {
|
for (var item in items) {
|
||||||
final stats = item.hiddenSpecs.sakeTasteStats;
|
final stats = item.hiddenSpecs.sakeTasteStats;
|
||||||
|
|
||||||
// Skip items with empty tasteStats (all zeros)
|
// Skip items with empty tasteStats (all zeros)
|
||||||
if (stats.aroma == 0 && stats.bitterness == 0 && stats.sweetness == 0 &&
|
if (stats.aroma == 0 && stats.bitterness == 0 && stats.sweetness == 0 &&
|
||||||
stats.acidity == 0 && stats.body == 0) {
|
stats.acidity == 0 && stats.body == 0) {
|
||||||
debugPrint('🍶 SKIPPED item (all zeros)');
|
debugPrint(' SKIPPED item (all zeros)');
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -39,10 +39,10 @@ class ShukoDiagnosisService {
|
||||||
count++;
|
count++;
|
||||||
}
|
}
|
||||||
|
|
||||||
debugPrint('🍶🍶🍶 Analyzed $count out of ${items.length} items');
|
debugPrint(' Analyzed $count out of ${items.length} items');
|
||||||
|
|
||||||
if (count == 0) {
|
if (count == 0) {
|
||||||
debugPrint('🍶🍶🍶 WARNING: No items to analyze, returning empty profile');
|
debugPrint(' WARNING: No items to analyze, returning empty profile');
|
||||||
return ShukoProfile.empty();
|
return ShukoProfile.empty();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -54,7 +54,7 @@ class ShukoDiagnosisService {
|
||||||
body: totalBody / count,
|
body: totalBody / count,
|
||||||
);
|
);
|
||||||
|
|
||||||
// 2. Determine Title based on dominant traits
|
// 2. Determine Title based on dominant traits
|
||||||
final title = _determineTitle(avgStats);
|
final title = _determineTitle(avgStats);
|
||||||
|
|
||||||
return ShukoProfile(
|
return ShukoProfile(
|
||||||
|
|
@ -67,69 +67,69 @@ class ShukoDiagnosisService {
|
||||||
}
|
}
|
||||||
|
|
||||||
ShukoTitle _determineTitle(SakeTasteStats stats) {
|
ShukoTitle _determineTitle(SakeTasteStats stats) {
|
||||||
// DEBUG: Print average stats
|
// DEBUG: Print average stats
|
||||||
debugPrint('🔍 DEBUG avgStats: aroma=${stats.aroma.toStringAsFixed(2)}, bitterness=${stats.bitterness.toStringAsFixed(2)}, sweetness=${stats.sweetness.toStringAsFixed(2)}, acidity=${stats.acidity.toStringAsFixed(2)}, body=${stats.body.toStringAsFixed(2)}');
|
debugPrint(' DEBUG avgStats: aroma=${stats.aroma.toStringAsFixed(2)}, bitterness=${stats.bitterness.toStringAsFixed(2)}, sweetness=${stats.sweetness.toStringAsFixed(2)}, acidity=${stats.acidity.toStringAsFixed(2)}, body=${stats.body.toStringAsFixed(2)}');
|
||||||
|
|
||||||
// Scoring-based logic to handle overlapping traits
|
// Scoring-based logic to handle overlapping traits
|
||||||
final Map<String, double> scores = {};
|
final Map<String, double> scores = {};
|
||||||
|
|
||||||
// 1. 辛口サムライ (Dry Samurai)
|
// 1. 辛口サムライ (Dry Samurai)
|
||||||
// High Bitterness (Sharpness) + Low Sweetness
|
// High Bitterness (Sharpness) + Low Sweetness
|
||||||
// Old: alcoholFeeling + Low sweetness
|
// Old: alcoholFeeling + Low sweetness
|
||||||
scores['辛口サムライ'] = _calculateDryScore(stats);
|
scores['辛口サムライ'] = _calculateDryScore(stats);
|
||||||
|
|
||||||
// 2. フルーティーマスター (Fruity Master)
|
// 2. フルーティーマスター (Fruity Master)
|
||||||
// High Aroma + High Sweetness (Modern Fruity Ginjo Style)
|
// High Aroma + High Sweetness (Modern Fruity Ginjo Style)
|
||||||
// Old: fruitiness + High sweetness
|
// Old: fruitiness + High sweetness
|
||||||
scores['フルーティーマスター'] = _calculateFruityScore(stats);
|
scores['フルーティーマスター'] = _calculateFruityScore(stats);
|
||||||
|
|
||||||
// 3. 旨口探求者 (Umami Explorer)
|
// 3. 旨口探求者 (Umami Explorer)
|
||||||
// High Body (Richness)
|
// High Body (Richness)
|
||||||
// Old: richness
|
// Old: richness
|
||||||
scores['旨口探求者'] = _calculateRichnessScore(stats);
|
scores['旨口探求者'] = _calculateRichnessScore(stats);
|
||||||
|
|
||||||
// 4. 香りの貴族 (Aroma Noble)
|
// 4. 香りの貴族 (Aroma Noble)
|
||||||
// High Aroma (dominant trait)
|
// High Aroma (dominant trait)
|
||||||
scores['香りの貴族'] = _calculateAromaScore(stats);
|
scores['香りの貴族'] = _calculateAromaScore(stats);
|
||||||
|
|
||||||
// 5. バランスの賢者 (Balance Sage)
|
// 5. バランスの賢者 (Balance Sage)
|
||||||
// All stats moderate and balanced
|
// All stats moderate and balanced
|
||||||
scores['バランスの賢者'] = _calculateBalanceScore(stats);
|
scores['バランスの賢者'] = _calculateBalanceScore(stats);
|
||||||
|
|
||||||
// DEBUG: Print all scores
|
// DEBUG: Print all scores
|
||||||
debugPrint('🔍 DEBUG scores: ${scores.entries.map((e) => '${e.key}=${e.value.toStringAsFixed(2)}').join(', ')}');
|
debugPrint(' DEBUG scores: ${scores.entries.map((e) => '${e.key}=${e.value.toStringAsFixed(2)}').join(', ')}');
|
||||||
|
|
||||||
// Find the title with the highest score
|
// Find the title with the highest score
|
||||||
final maxEntry = scores.entries.reduce((a, b) => a.value > b.value ? a : b);
|
final maxEntry = scores.entries.reduce((a, b) => a.value > b.value ? a : b);
|
||||||
|
|
||||||
debugPrint('🔍 DEBUG winner: ${maxEntry.key} with score ${maxEntry.value.toStringAsFixed(2)}');
|
debugPrint(' DEBUG winner: ${maxEntry.key} with score ${maxEntry.value.toStringAsFixed(2)}');
|
||||||
|
|
||||||
// Threshold: require minimum score to avoid false positives
|
// Threshold: require minimum score to avoid false positives
|
||||||
// Lowered to 1.5 to be more forgiving for "Standard" sake
|
// Lowered to 1.5 to be more forgiving for "Standard" sake
|
||||||
if (maxEntry.value < 1.0) {
|
if (maxEntry.value < 1.0) {
|
||||||
debugPrint('🔍 DEBUG: Score too low (${maxEntry.value.toStringAsFixed(2)} < 1.0), returning default title');
|
debugPrint(' DEBUG: Score too low (${maxEntry.value.toStringAsFixed(2)} < 1.0), returning default title');
|
||||||
// Proposed New Default Titles
|
// Proposed New Default Titles
|
||||||
return const ShukoTitle(
|
return const ShukoTitle(
|
||||||
title: '酒道の旅人',
|
title: '酒道の旅人',
|
||||||
description: '未知なる味を求めて各地を巡る、終わりのない旅の途中。',
|
description: '未知なる味を求めて各地を巡る、終わりのない旅の途中。',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return the winning title
|
// Return the winning title
|
||||||
return _getTitleByName(maxEntry.key);
|
return _getTitleByName(maxEntry.key);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scoring functions for each type
|
// Scoring functions for each type
|
||||||
double _calculateDryScore(SakeTasteStats stats) {
|
double _calculateDryScore(SakeTasteStats stats) {
|
||||||
double score = 0;
|
double score = 0;
|
||||||
// Dry = Sharp/Bitter + Not Sweet
|
// Dry = Sharp/Bitter + Not Sweet
|
||||||
if (stats.bitterness > 0.1) {
|
if (stats.bitterness > 0.1) {
|
||||||
if (stats.bitterness > 3.0) score += (stats.bitterness - 3.0) * 2; // Lowered from 3.2
|
if (stats.bitterness > 3.0) score += (stats.bitterness - 3.0) * 2; // Lowered from 3.2
|
||||||
|
|
||||||
// Also verify Acidity contributions (Acid + Bitter = Dry)
|
// Also verify Acidity contributions (Acid + Bitter = Dry)
|
||||||
if (stats.acidity > 3.0) score += (stats.acidity - 3.0);
|
if (stats.acidity > 3.0) score += (stats.acidity - 3.0);
|
||||||
|
|
||||||
// Penalize if too sweet
|
// Penalize if too sweet
|
||||||
if (stats.sweetness < 2.5) {
|
if (stats.sweetness < 2.5) {
|
||||||
score += (2.5 - stats.sweetness) * 2;
|
score += (2.5 - stats.sweetness) * 2;
|
||||||
} else if (stats.sweetness > 3.5) {
|
} else if (stats.sweetness > 3.5) {
|
||||||
|
|
@ -141,14 +141,14 @@ class ShukoDiagnosisService {
|
||||||
|
|
||||||
double _calculateFruityScore(SakeTasteStats stats) {
|
double _calculateFruityScore(SakeTasteStats stats) {
|
||||||
double score = 0;
|
double score = 0;
|
||||||
// Fruity = High Aroma + Moderate/High Sweetness
|
// Fruity = High Aroma + Moderate/High Sweetness
|
||||||
if (stats.aroma > 0.1) {
|
if (stats.aroma > 0.1) {
|
||||||
if (stats.aroma > 2.8) score += (stats.aroma - 2.8) * 1.5; // Lowered from 3.0
|
if (stats.aroma > 2.8) score += (stats.aroma - 2.8) * 1.5; // Lowered from 3.0
|
||||||
|
|
||||||
// Verify Sweetness support
|
// Verify Sweetness support
|
||||||
if (stats.sweetness > 2.8) score += (stats.sweetness - 2.8) * 1.5;
|
if (stats.sweetness > 2.8) score += (stats.sweetness - 2.8) * 1.5;
|
||||||
|
|
||||||
// Bonus if Body is not too heavy (Light + Sweet + Aroma = Fruity)
|
// Bonus if Body is not too heavy (Light + Sweet + Aroma = Fruity)
|
||||||
if (stats.body < 3.5) score += 0.5;
|
if (stats.body < 3.5) score += 0.5;
|
||||||
}
|
}
|
||||||
return score;
|
return score;
|
||||||
|
|
@ -156,12 +156,12 @@ class ShukoDiagnosisService {
|
||||||
|
|
||||||
double _calculateRichnessScore(SakeTasteStats stats) {
|
double _calculateRichnessScore(SakeTasteStats stats) {
|
||||||
double score = 0;
|
double score = 0;
|
||||||
// Richness = High Body (Kokumi) + Sweetness or Bitterness
|
// Richness = High Body (Kokumi) + Sweetness or Bitterness
|
||||||
if (stats.body > 0.1) {
|
if (stats.body > 0.1) {
|
||||||
// Body is the primary driver
|
// Body is the primary driver
|
||||||
if (stats.body > 3.0) score += (stats.body - 3.0) * 2.5; // Lowered from 3.3
|
if (stats.body > 3.0) score += (stats.body - 3.0) * 2.5; // Lowered from 3.3
|
||||||
|
|
||||||
// Bonus for complexity
|
// Bonus for complexity
|
||||||
if (stats.bitterness > 3.0) score += 0.5;
|
if (stats.bitterness > 3.0) score += 0.5;
|
||||||
}
|
}
|
||||||
return score;
|
return score;
|
||||||
|
|
@ -169,12 +169,12 @@ class ShukoDiagnosisService {
|
||||||
|
|
||||||
double _calculateAromaScore(SakeTasteStats stats) {
|
double _calculateAromaScore(SakeTasteStats stats) {
|
||||||
double score = 0;
|
double score = 0;
|
||||||
// Pure Aroma focus (Daiginjo style)
|
// Pure Aroma focus (Daiginjo style)
|
||||||
// Lowered threshold significantly to capture "Aroma Type" even if not extreme
|
// Lowered threshold significantly to capture "Aroma Type" even if not extreme
|
||||||
if (stats.aroma > 3.0) {
|
if (stats.aroma > 3.0) {
|
||||||
score += (stats.aroma - 3.0) * 3;
|
score += (stats.aroma - 3.0) * 3;
|
||||||
}
|
}
|
||||||
// Boost score if it is the dominant trait
|
// Boost score if it is the dominant trait
|
||||||
if (stats.aroma > stats.body && stats.aroma > stats.bitterness) {
|
if (stats.aroma > stats.body && stats.aroma > stats.bitterness) {
|
||||||
score += 1.0;
|
score += 1.0;
|
||||||
}
|
}
|
||||||
|
|
@ -184,14 +184,14 @@ class ShukoDiagnosisService {
|
||||||
double _calculateBalanceScore(SakeTasteStats stats) {
|
double _calculateBalanceScore(SakeTasteStats stats) {
|
||||||
double score = 0;
|
double score = 0;
|
||||||
|
|
||||||
// Check range (Max - Min)
|
// Check range (Max - Min)
|
||||||
final values = [stats.aroma, stats.sweetness, stats.acidity, stats.bitterness, stats.body];
|
final values = [stats.aroma, stats.sweetness, stats.acidity, stats.bitterness, stats.body];
|
||||||
final maxVal = values.reduce((a, b) => a > b ? a : b);
|
final maxVal = values.reduce((a, b) => a > b ? a : b);
|
||||||
final minVal = values.reduce((a, b) => a < b ? a : b);
|
final minVal = values.reduce((a, b) => a < b ? a : b);
|
||||||
final spread = maxVal - minVal;
|
final spread = maxVal - minVal;
|
||||||
|
|
||||||
// Strict requirement for "Balance":
|
// Strict requirement for "Balance":
|
||||||
// The difference between the highest and lowest trait must be small.
|
// The difference between the highest and lowest trait must be small.
|
||||||
if (spread > 1.5) {
|
if (spread > 1.5) {
|
||||||
return 0; // Not balanced if there's a spike
|
return 0; // Not balanced if there's a spike
|
||||||
}
|
}
|
||||||
|
|
@ -199,7 +199,7 @@ class ShukoDiagnosisService {
|
||||||
int validStats = 0;
|
int validStats = 0;
|
||||||
double sumDiffFrom3 = 0;
|
double sumDiffFrom3 = 0;
|
||||||
|
|
||||||
// Check deviation from 3.0 (Center)
|
// Check deviation from 3.0 (Center)
|
||||||
void checkStat(double val) {
|
void checkStat(double val) {
|
||||||
if (val > 0.1) {
|
if (val > 0.1) {
|
||||||
validStats++;
|
validStats++;
|
||||||
|
|
@ -215,7 +215,7 @@ class ShukoDiagnosisService {
|
||||||
|
|
||||||
if (validStats >= 3) {
|
if (validStats >= 3) {
|
||||||
double avgDev = sumDiffFrom3 / validStats;
|
double avgDev = sumDiffFrom3 / validStats;
|
||||||
// If average deviation is small (< 0.7), it's balanced
|
// If average deviation is small (< 0.7), it's balanced
|
||||||
if (avgDev < 0.7) {
|
if (avgDev < 0.7) {
|
||||||
score = (0.8 - avgDev) * 5; // Higher score for tighter balance
|
score = (0.8 - avgDev) * 5; // Higher score for tighter balance
|
||||||
}
|
}
|
||||||
|
|
@ -252,7 +252,7 @@ class ShukoDiagnosisService {
|
||||||
description: '偏りなく様々な酒を楽しむ、オールラウンダー。',
|
description: '偏りなく様々な酒を楽しむ、オールラウンダー。',
|
||||||
);
|
);
|
||||||
default:
|
default:
|
||||||
// New Default Title
|
// New Default Title
|
||||||
return const ShukoTitle(
|
return const ShukoTitle(
|
||||||
title: '酒道の旅人',
|
title: '酒道の旅人',
|
||||||
description: '未知なる味を求めて各地を巡る、終わりのない旅の途中。',
|
description: '未知なる味を求めて各地を巡る、終わりのない旅の途中。',
|
||||||
|
|
@ -260,7 +260,7 @@ class ShukoDiagnosisService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// v1.1: Personalization Logic
|
// v1.1: Personalization Logic
|
||||||
String getGreeting(String? nickname) {
|
String getGreeting(String? nickname) {
|
||||||
if (nickname == null || nickname.trim().isEmpty) {
|
if (nickname == null || nickname.trim().isEmpty) {
|
||||||
return 'ようこそ!';
|
return 'ようこそ!';
|
||||||
|
|
@ -273,7 +273,7 @@ class ShukoDiagnosisService {
|
||||||
|
|
||||||
String newTitle = original.title;
|
String newTitle = original.title;
|
||||||
|
|
||||||
// Simple customization logic
|
// Simple customization logic
|
||||||
if (gender == 'female') {
|
if (gender == 'female') {
|
||||||
if (newTitle.contains('サムライ')) newTitle = newTitle.replaceAll('サムライ', '麗人');
|
if (newTitle.contains('サムライ')) newTitle = newTitle.replaceAll('サムライ', '麗人');
|
||||||
if (newTitle.contains('貴族')) newTitle = newTitle.replaceAll('貴族', 'プリンセス');
|
if (newTitle.contains('貴族')) newTitle = newTitle.replaceAll('貴族', 'プリンセス');
|
||||||
|
|
|
||||||
|
|
@ -46,9 +46,13 @@ redisClient.on('connect', () => {
|
||||||
const genAI = new GoogleGenerativeAI(API_KEY);
|
const genAI = new GoogleGenerativeAI(API_KEY);
|
||||||
const model = genAI.getGenerativeModel({
|
const model = genAI.getGenerativeModel({
|
||||||
model: "gemini-2.5-flash",
|
model: "gemini-2.5-flash",
|
||||||
|
systemInstruction: "あなたは画像内のテキストを一字一句正確に読み取る専門家です。" +
|
||||||
|
"ラベルに記載された銘柄名・蔵元名は絶対に変更・補完しないでください。" +
|
||||||
|
"あなたの知識でラベルの文字を上書きすることは厳禁です。" +
|
||||||
|
"ラベルに「東魁」とあれば、「東魁盛」を知っていても必ず「東魁」と出力してください。",
|
||||||
generationConfig: {
|
generationConfig: {
|
||||||
responseMimeType: "application/json",
|
responseMimeType: "application/json",
|
||||||
temperature: 0.2,
|
temperature: 0,
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue