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;
|
||
}
|
||
}
|