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