fix: code review fixes — data integrity, safety, architecture
C-2: draft_service — $i+1 → ${i+1} 文字列補間バグ修正
C-1: sake_item — setter内の unawaited save() を削除(呼び出し元で明示的に await)
H-2: sake_detail_screen — 再解析前に実ファイル存在チェック追加
M-4: gemini_exceptions.dart 新規作成、[CONGESTION]文字列マッチ→型チェックに変更
C-4: main.dart — migration_completed フラグ→ migration_version 番号管理に移行
既存ユーザーのデータは migration_version=1 扱いで安全に互換維持
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
30c15b553d
commit
fedfc6fa62
|
|
@ -51,15 +51,22 @@ void main() async {
|
||||||
Hive.openBox<MenuSettings>('menu_settings'),
|
Hive.openBox<MenuSettings>('menu_settings'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Run Phase 0 Migration (Only once)
|
// Migration — バージョン番号で管理(単一フラグだと将来の追加マイグレーションが走らないため)
|
||||||
|
// migration_completed=true の旧ユーザーはバージョン 1 扱いとして互換性を維持する
|
||||||
|
const int currentMigrationVersion = 1;
|
||||||
final box = Hive.box('settings');
|
final box = Hive.box('settings');
|
||||||
final migrationCompleted = box.get('migration_completed', defaultValue: false);
|
final legacyCompleted = box.get('migration_completed', defaultValue: false) as bool;
|
||||||
if (!migrationCompleted) {
|
final storedVersion = legacyCompleted
|
||||||
debugPrint('🚀 Running MigrationService...');
|
? box.get('migration_version', defaultValue: 1) as int // 旧ユーザー: 既に完了済み=v1
|
||||||
|
: box.get('migration_version', defaultValue: 0) as int; // 新規ユーザー: 未実行=v0
|
||||||
|
|
||||||
|
if (storedVersion < currentMigrationVersion) {
|
||||||
|
debugPrint('🚀 Running MigrationService (v$storedVersion → v$currentMigrationVersion)...');
|
||||||
await MigrationService.runMigration();
|
await MigrationService.runMigration();
|
||||||
await box.put('migration_completed', true);
|
await box.put('migration_version', currentMigrationVersion);
|
||||||
|
await box.put('migration_completed', true); // 旧フラグも維持(後方互換)
|
||||||
} else {
|
} else {
|
||||||
debugPrint('✅ Migration already completed. Skipping.');
|
debugPrint('✅ Migration up to date (v$storedVersion). Skipping.');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ✅ AI解析キャッシュは使うときに初期化する(Lazy initialization)
|
// ✅ AI解析キャッシュは使うときに初期化する(Lazy initialization)
|
||||||
|
|
|
||||||
|
|
@ -157,11 +157,12 @@ class SakeItem extends HiveObject {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Allow setting for UI updates
|
// Allow setting for UI updates (呼び出し元で必ず await sakeItem.save() すること)
|
||||||
set displayData(DisplayData val) {
|
set displayData(DisplayData val) {
|
||||||
_displayData = val;
|
_displayData = val;
|
||||||
save(); // Auto-save on set? Or just update memory. HiveObject usually requires save().
|
// save() はここで呼ばない。setter は同期のため await できず、
|
||||||
// Better to just update memory here.
|
// unawaited save() はデータ消失リスクがある。
|
||||||
|
// 呼び出し元(sakenowa_auto_matching_service.dart)で明示的に await save() する。
|
||||||
}
|
}
|
||||||
|
|
||||||
HiddenSpecs get hiddenSpecs {
|
HiddenSpecs get hiddenSpecs {
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import 'package:uuid/uuid.dart';
|
||||||
import 'package:gal/gal.dart';
|
import 'package:gal/gal.dart';
|
||||||
|
|
||||||
import '../services/gemini_service.dart';
|
import '../services/gemini_service.dart';
|
||||||
|
import '../services/gemini_exceptions.dart';
|
||||||
import '../services/image_compression_service.dart';
|
import '../services/image_compression_service.dart';
|
||||||
import '../services/gamification_service.dart'; // Badge check
|
import '../services/gamification_service.dart'; // Badge check
|
||||||
import '../services/network_service.dart';
|
import '../services/network_service.dart';
|
||||||
|
|
@ -568,10 +569,8 @@ class _CameraScreenState extends ConsumerState<CameraScreen> with SingleTickerPr
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
Navigator.of(context).pop(); // Close AnalyzingDialog
|
Navigator.of(context).pop(); // Close AnalyzingDialog
|
||||||
|
|
||||||
final errStr = e.toString();
|
|
||||||
|
|
||||||
// AIサーバー混雑(503)→ ドラフト保存してオフライン時と同じ扱いに
|
// AIサーバー混雑(503)→ ドラフト保存してオフライン時と同じ扱いに
|
||||||
if (errStr.contains('[CONGESTION]')) {
|
if (e is GeminiCongestionException) {
|
||||||
try {
|
try {
|
||||||
await DraftService.saveDraft(_capturedImages);
|
await DraftService.saveDraft(_capturedImages);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
@ -609,6 +608,7 @@ class _CameraScreenState extends ConsumerState<CameraScreen> with SingleTickerPr
|
||||||
}
|
}
|
||||||
|
|
||||||
// Quota エラー(429)→ ロックアウト
|
// Quota エラー(429)→ ロックアウト
|
||||||
|
final errStr = e.toString();
|
||||||
if (errStr.contains('Quota') || errStr.contains('429')) {
|
if (errStr.contains('Quota') || errStr.contains('429')) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_quotaLockoutTime = DateTime.now().add(const Duration(minutes: 1));
|
_quotaLockoutTime = DateTime.now().add(const Duration(minutes: 1));
|
||||||
|
|
|
||||||
|
|
@ -323,20 +323,40 @@ class _SakeDetailScreenState extends ConsumerState<SakeDetailScreen> {
|
||||||
|
|
||||||
if (_sake.displayData.imagePaths.isEmpty) return;
|
if (_sake.displayData.imagePaths.isEmpty) return;
|
||||||
|
|
||||||
|
// 実ファイルの存在確認(削除済み画像でAPIエラーになるのを防ぐ)
|
||||||
|
final existingPaths = <String>[];
|
||||||
|
for (final path in _sake.displayData.imagePaths) {
|
||||||
|
if (await File(path).exists()) {
|
||||||
|
existingPaths.add(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// mounted チェック後に context 依存オブジェクトをキャプチャ(async gap 対策)
|
||||||
|
if (!mounted) return;
|
||||||
|
// ignore: use_build_context_synchronously
|
||||||
|
final nav = Navigator.of(context);
|
||||||
|
// ignore: use_build_context_synchronously
|
||||||
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
|
|
||||||
|
if (existingPaths.isEmpty) {
|
||||||
|
messenger.showSnackBar(
|
||||||
|
const SnackBar(content: Text('画像ファイルが見つかりません。再解析できません。')),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setState(() => _isAnalyzing = true);
|
setState(() => _isAnalyzing = true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// ignore: use_build_context_synchronously
|
||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context, // ignore: use_build_context_synchronously
|
||||||
barrierDismissible: false,
|
barrierDismissible: false,
|
||||||
builder: (context) => const AnalyzingDialog(),
|
builder: (context) => const AnalyzingDialog(),
|
||||||
);
|
);
|
||||||
|
|
||||||
final geminiService = GeminiService();
|
final geminiService = GeminiService();
|
||||||
// 既存の画像パスを使用(すでに圧縮済みの想定)
|
|
||||||
// 注: 既存のデータは未圧縮の可能性があるため、一括圧縮機能で対応
|
|
||||||
// forceRefresh: true でキャッシュを無視して再解析
|
// forceRefresh: true でキャッシュを無視して再解析
|
||||||
final result = await geminiService.analyzeSakeLabel(_sake.displayData.imagePaths, forceRefresh: true);
|
final result = await geminiService.analyzeSakeLabel(existingPaths, forceRefresh: true);
|
||||||
|
|
||||||
final newItem = _sake.copyWith(
|
final newItem = _sake.copyWith(
|
||||||
name: result.name ?? _sake.displayData.displayName,
|
name: result.name ?? _sake.displayData.displayName,
|
||||||
|
|
@ -365,16 +385,16 @@ class _SakeDetailScreenState extends ConsumerState<SakeDetailScreen> {
|
||||||
_sake = newItem;
|
_sake = newItem;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (context.mounted) {
|
if (mounted) {
|
||||||
Navigator.pop(context); // Close dialog
|
nav.pop(); // Close dialog
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
messenger.showSnackBar(
|
||||||
const SnackBar(content: Text('再解析が完了しました')),
|
const SnackBar(content: Text('再解析が完了しました')),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (context.mounted) {
|
if (mounted) {
|
||||||
Navigator.pop(context); // Close dialog (safely pops the top route, hopefully the 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')) {
|
||||||
|
|
@ -383,7 +403,7 @@ class _SakeDetailScreenState extends ConsumerState<SakeDetailScreen> {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
messenger.showSnackBar(
|
||||||
SnackBar(content: Text('エラー: $e')),
|
SnackBar(content: Text('エラー: $e')),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -217,10 +217,10 @@ class DraftService {
|
||||||
onProgress?.call(i + 1, total);
|
onProgress?.call(i + 1, total);
|
||||||
await analyzeDraft(itemKey);
|
await analyzeDraft(itemKey);
|
||||||
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');
|
||||||
// 失敗してもループは継続(他のDraftを解析)
|
// 失敗してもループは継続(他のDraftを解析)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
// Gemini API 専用例外クラス
|
||||||
|
// 文字列マッチングではなく型で分岐できるようにする
|
||||||
|
|
||||||
|
/// API サーバーの混雑(503 UNAVAILABLE)でリトライを使い切った
|
||||||
|
class GeminiCongestionException implements Exception {
|
||||||
|
const GeminiCongestionException();
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => 'GeminiCongestionException: AIサーバーが混雑しています。しばらく待ってから再試行してください。';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// API キーが空または無効
|
||||||
|
class GeminiApiKeyException implements Exception {
|
||||||
|
const GeminiApiKeyException(this.message);
|
||||||
|
final String message;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => 'GeminiApiKeyException: $message';
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,7 @@ import 'device_service.dart';
|
||||||
import 'package:google_generative_ai/google_generative_ai.dart';
|
import 'package:google_generative_ai/google_generative_ai.dart';
|
||||||
import '../secrets.dart';
|
import '../secrets.dart';
|
||||||
import 'analysis_cache_service.dart';
|
import 'analysis_cache_service.dart';
|
||||||
|
import 'gemini_exceptions.dart';
|
||||||
|
|
||||||
|
|
||||||
class GeminiService {
|
class GeminiService {
|
||||||
|
|
@ -349,8 +350,7 @@ $extractedText
|
||||||
if (isLastAttempt || !is503) {
|
if (isLastAttempt || !is503) {
|
||||||
// 最終試行 or 503以外のエラーはそのまま投げる
|
// 最終試行 or 503以外のエラーはそのまま投げる
|
||||||
if (is503) {
|
if (is503) {
|
||||||
// [CONGESTION] マーカー付きで投げる → camera_screen でドラフト保存へ
|
throw const GeminiCongestionException();
|
||||||
throw Exception('[CONGESTION] AIサーバーが混雑しています。解析待ちとして保存します。');
|
|
||||||
}
|
}
|
||||||
throw Exception('AI解析エラー(Direct): $e');
|
throw Exception('AI解析エラー(Direct): $e');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue