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:
Ponshu Developer 2026-04-10 08:26:53 +09:00
parent 30c15b553d
commit fedfc6fa62
7 changed files with 74 additions and 27 deletions

View File

@ -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

View File

@ -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 {

View File

@ -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));

View File

@ -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')),
); );
} }

View File

@ -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を解析

View File

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

View File

@ -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');
} }