272 lines
8.3 KiB
Dart
272 lines
8.3 KiB
Dart
|
|
import 'package:flutter/foundation.dart';
|
|||
|
|
import 'package:hive_flutter/hive_flutter.dart';
|
|||
|
|
import 'package:uuid/uuid.dart';
|
|||
|
|
import '../models/sake_item.dart';
|
|||
|
|
import 'gemini_service.dart';
|
|||
|
|
|
|||
|
|
/// Draft(解析待ちアイテム)管理サービス
|
|||
|
|
///
|
|||
|
|
/// Phase 1緊急対応: オフライン時に撮影した写真を一時保存し、
|
|||
|
|
/// オンライン復帰時に自動解析する機能を提供します。
|
|||
|
|
///
|
|||
|
|
/// 使用フロー:
|
|||
|
|
/// 1. オフライン時: saveDraft() で写真を一時保存
|
|||
|
|
/// 2. オンライン復帰時: getPendingDrafts() で一覧取得
|
|||
|
|
/// 3. analyzeDraft() で個別解析、または analyzeAllDrafts() で一括解析
|
|||
|
|
class DraftService {
|
|||
|
|
static const _uuid = Uuid();
|
|||
|
|
|
|||
|
|
/// Draft(解析待ち)アイテムを保存
|
|||
|
|
///
|
|||
|
|
/// オフライン時にカメラで撮影した写真を一時保存します。
|
|||
|
|
/// 写真パスはそのまま保持され、後でAI解析時に使用されます。
|
|||
|
|
///
|
|||
|
|
/// [photoPaths] 保存した写真の絶対パスリスト(ギャラリーに既に保存済みであること)
|
|||
|
|
///
|
|||
|
|
/// Returns: 作成されたDraft SakeItemのkey
|
|||
|
|
///
|
|||
|
|
/// Usage:
|
|||
|
|
/// ```dart
|
|||
|
|
/// final draftKey = await DraftService.saveDraft([imagePath1, imagePath2]);
|
|||
|
|
/// ```
|
|||
|
|
static Future<String> saveDraft(List<String> photoPaths) async {
|
|||
|
|
try {
|
|||
|
|
final box = Hive.box<SakeItem>('sake_items');
|
|||
|
|
|
|||
|
|
// 🔧 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,
|
|||
|
|
),
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
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<List<SakeItem>> getPendingDrafts() async {
|
|||
|
|
try {
|
|||
|
|
final box = Hive.box<SakeItem>('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 [];
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// 解析待ちDraftの件数を取得
|
|||
|
|
///
|
|||
|
|
/// Returns: 解析待ちアイテム数
|
|||
|
|
static Future<int> 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<SakeAnalysisResult> analyzeDraft(dynamic itemKey) async {
|
|||
|
|
final box = Hive.box<SakeItem>('sake_items');
|
|||
|
|
final item = box.get(itemKey);
|
|||
|
|
|
|||
|
|
if (item == null) {
|
|||
|
|
throw Exception('Draft item not found: $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');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
debugPrint('🔍 Analyzing draft: $itemKey (${item.displayData.displayName})');
|
|||
|
|
|
|||
|
|
// 🔧 FIX: 複数画像がある場合はすべて使用
|
|||
|
|
final imagePaths = item.displayData.imagePaths;
|
|||
|
|
final pathsToAnalyze = imagePaths.isNotEmpty ? imagePaths : [photoPath];
|
|||
|
|
|
|||
|
|
// Gemini Vision API呼び出し
|
|||
|
|
final geminiService = GeminiService();
|
|||
|
|
final result = await geminiService.analyzeSakeLabel(pathsToAnalyze);
|
|||
|
|
|
|||
|
|
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,
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
// 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;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// すべての解析待ちDraftを一括解析
|
|||
|
|
///
|
|||
|
|
/// Returns: {成功件数, 失敗件数, エラーメッセージリスト}
|
|||
|
|
///
|
|||
|
|
/// Usage:
|
|||
|
|
/// ```dart
|
|||
|
|
/// final result = await DraftService.analyzeAllDrafts((progress, total) {
|
|||
|
|
/// print('進捗: $progress / $total');
|
|||
|
|
/// });
|
|||
|
|
/// print('成功: ${result['success']}, 失敗: ${result['failed']}');
|
|||
|
|
/// ```
|
|||
|
|
static Future<Map<String, dynamic>> analyzeAllDrafts({
|
|||
|
|
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': []};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
int successCount = 0;
|
|||
|
|
int failedCount = 0;
|
|||
|
|
final List<String> errors = [];
|
|||
|
|
|
|||
|
|
debugPrint('🚀 Analyzing $total drafts...');
|
|||
|
|
|
|||
|
|
for (int i = 0; i < total; i++) {
|
|||
|
|
final draft = drafts[i];
|
|||
|
|
final itemKey = draft.key;
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
onProgress?.call(i + 1, total);
|
|||
|
|
await analyzeDraft(itemKey);
|
|||
|
|
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');
|
|||
|
|
// 失敗してもループは継続(他のDraftを解析)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
debugPrint('🎉 Batch analysis completed: $successCount成功, $failedCount失敗');
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
'success': successCount,
|
|||
|
|
'failed': failedCount,
|
|||
|
|
'errors': errors,
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Draft を削除(解析を諦める場合)
|
|||
|
|
///
|
|||
|
|
/// [itemKey] DraftアイテムのHive key
|
|||
|
|
///
|
|||
|
|
/// Usage:
|
|||
|
|
/// ```dart
|
|||
|
|
/// await DraftService.deleteDraft(itemKey);
|
|||
|
|
/// ```
|
|||
|
|
static Future<void> deleteDraft(dynamic itemKey) async {
|
|||
|
|
final box = Hive.box<SakeItem>('sake_items');
|
|||
|
|
final item = box.get(itemKey);
|
|||
|
|
|
|||
|
|
if (item != null && item.isPendingAnalysis) {
|
|||
|
|
await box.delete(itemKey);
|
|||
|
|
debugPrint('🗑️ Draft deleted: $itemKey');
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// すべてのDraftを削除
|
|||
|
|
///
|
|||
|
|
/// Returns: 削除件数
|
|||
|
|
static Future<int> deleteAllDrafts() async {
|
|||
|
|
final drafts = await getPendingDrafts();
|
|||
|
|
final count = drafts.length;
|
|||
|
|
|
|||
|
|
for (final draft in drafts) {
|
|||
|
|
await deleteDraft(draft.key);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
debugPrint('🗑️ All drafts deleted: $count件');
|
|||
|
|
return count;
|
|||
|
|
}
|
|||
|
|
}
|