From fedfc6fa621e575f3737613800fe8ef338ef8a3d Mon Sep 17 00:00:00 2001 From: Ponshu Developer Date: Fri, 10 Apr 2026 08:26:53 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20code=20review=20fixes=20=E2=80=94=20data?= =?UTF-8?q?=20integrity,=20safety,=20architecture?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- lib/main.dart | 19 ++++++++----- lib/models/sake_item.dart | 7 ++--- lib/screens/camera_screen.dart | 6 ++--- lib/screens/sake_detail_screen.dart | 42 +++++++++++++++++++++-------- lib/services/draft_service.dart | 4 +-- lib/services/gemini_exceptions.dart | 19 +++++++++++++ lib/services/gemini_service.dart | 4 +-- 7 files changed, 74 insertions(+), 27 deletions(-) create mode 100644 lib/services/gemini_exceptions.dart diff --git a/lib/main.dart b/lib/main.dart index d54da7a..64b46cc 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -51,15 +51,22 @@ void main() async { Hive.openBox('menu_settings'), ]); - // Run Phase 0 Migration (Only once) + // Migration — バージョン番号で管理(単一フラグだと将来の追加マイグレーションが走らないため) + // migration_completed=true の旧ユーザーはバージョン 1 扱いとして互換性を維持する + const int currentMigrationVersion = 1; final box = Hive.box('settings'); - final migrationCompleted = box.get('migration_completed', defaultValue: false); - if (!migrationCompleted) { - debugPrint('🚀 Running MigrationService...'); + final legacyCompleted = box.get('migration_completed', defaultValue: false) as bool; + final storedVersion = legacyCompleted + ? 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 box.put('migration_completed', true); + await box.put('migration_version', currentMigrationVersion); + await box.put('migration_completed', true); // 旧フラグも維持(後方互換) } else { - debugPrint('✅ Migration already completed. Skipping.'); + debugPrint('✅ Migration up to date (v$storedVersion). Skipping.'); } // ✅ AI解析キャッシュは使うときに初期化する(Lazy initialization) diff --git a/lib/models/sake_item.dart b/lib/models/sake_item.dart index 701d55f..c3d6c98 100644 --- a/lib/models/sake_item.dart +++ b/lib/models/sake_item.dart @@ -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) { _displayData = val; - save(); // Auto-save on set? Or just update memory. HiveObject usually requires save(). - // Better to just update memory here. + // save() はここで呼ばない。setter は同期のため await できず、 + // unawaited save() はデータ消失リスクがある。 + // 呼び出し元(sakenowa_auto_matching_service.dart)で明示的に await save() する。 } HiddenSpecs get hiddenSpecs { diff --git a/lib/screens/camera_screen.dart b/lib/screens/camera_screen.dart index b43df0a..c47d9ac 100644 --- a/lib/screens/camera_screen.dart +++ b/lib/screens/camera_screen.dart @@ -10,6 +10,7 @@ import 'package:uuid/uuid.dart'; import 'package:gal/gal.dart'; import '../services/gemini_service.dart'; +import '../services/gemini_exceptions.dart'; import '../services/image_compression_service.dart'; import '../services/gamification_service.dart'; // Badge check import '../services/network_service.dart'; @@ -568,10 +569,8 @@ class _CameraScreenState extends ConsumerState with SingleTickerPr if (mounted) { Navigator.of(context).pop(); // Close AnalyzingDialog - final errStr = e.toString(); - // AIサーバー混雑(503)→ ドラフト保存してオフライン時と同じ扱いに - if (errStr.contains('[CONGESTION]')) { + if (e is GeminiCongestionException) { try { await DraftService.saveDraft(_capturedImages); if (!mounted) return; @@ -609,6 +608,7 @@ class _CameraScreenState extends ConsumerState with SingleTickerPr } // Quota エラー(429)→ ロックアウト + final errStr = e.toString(); if (errStr.contains('Quota') || errStr.contains('429')) { setState(() { _quotaLockoutTime = DateTime.now().add(const Duration(minutes: 1)); diff --git a/lib/screens/sake_detail_screen.dart b/lib/screens/sake_detail_screen.dart index 0f0b305..6a9d2ba 100644 --- a/lib/screens/sake_detail_screen.dart +++ b/lib/screens/sake_detail_screen.dart @@ -323,20 +323,40 @@ class _SakeDetailScreenState extends ConsumerState { if (_sake.displayData.imagePaths.isEmpty) return; + // 実ファイルの存在確認(削除済み画像でAPIエラーになるのを防ぐ) + final existingPaths = []; + 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); try { + // ignore: use_build_context_synchronously showDialog( - context: context, + context: context, // ignore: use_build_context_synchronously barrierDismissible: false, builder: (context) => const AnalyzingDialog(), ); final geminiService = GeminiService(); - // 既存の画像パスを使用(すでに圧縮済みの想定) - // 注: 既存のデータは未圧縮の可能性があるため、一括圧縮機能で対応 // forceRefresh: true でキャッシュを無視して再解析 - final result = await geminiService.analyzeSakeLabel(_sake.displayData.imagePaths, forceRefresh: true); + final result = await geminiService.analyzeSakeLabel(existingPaths, forceRefresh: true); final newItem = _sake.copyWith( name: result.name ?? _sake.displayData.displayName, @@ -365,17 +385,17 @@ class _SakeDetailScreenState extends ConsumerState { _sake = newItem; }); - if (context.mounted) { - Navigator.pop(context); // Close dialog - ScaffoldMessenger.of(context).showSnackBar( + if (mounted) { + nav.pop(); // Close dialog + messenger.showSnackBar( const SnackBar(content: Text('再解析が完了しました')), ); } } catch (e) { - if (context.mounted) { - Navigator.pop(context); // Close dialog (safely pops the top route, hopefully the dialog) - + if (mounted) { + nav.pop(); // Close dialog + // Check for Quota Error to set Lockout if (e.toString().contains('Quota') || e.toString().contains('429')) { setState(() { @@ -383,7 +403,7 @@ class _SakeDetailScreenState extends ConsumerState { }); } - ScaffoldMessenger.of(context).showSnackBar( + messenger.showSnackBar( SnackBar(content: Text('エラー: $e')), ); } diff --git a/lib/services/draft_service.dart b/lib/services/draft_service.dart index 197e38c..7747066 100644 --- a/lib/services/draft_service.dart +++ b/lib/services/draft_service.dart @@ -217,10 +217,10 @@ class DraftService { onProgress?.call(i + 1, total); await analyzeDraft(itemKey); successCount++; - debugPrint('✅ [$i+1/$total] Success: ${draft.displayData.displayName}'); + debugPrint('✅ [${i+1}/$total] Success: ${draft.displayData.displayName}'); } catch (e) { failedCount++; - final errorMsg = '[$i+1/$total] ${draft.displayData.displayName}: $e'; + final errorMsg = '[${i+1}/$total] ${draft.displayData.displayName}: $e'; errors.add(errorMsg); debugPrint('❌ $errorMsg'); // 失敗してもループは継続(他のDraftを解析) diff --git a/lib/services/gemini_exceptions.dart b/lib/services/gemini_exceptions.dart new file mode 100644 index 0000000..3a4e559 --- /dev/null +++ b/lib/services/gemini_exceptions.dart @@ -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'; +} diff --git a/lib/services/gemini_service.dart b/lib/services/gemini_service.dart index 576c5f0..771e2ba 100644 --- a/lib/services/gemini_service.dart +++ b/lib/services/gemini_service.dart @@ -6,6 +6,7 @@ import 'device_service.dart'; import 'package:google_generative_ai/google_generative_ai.dart'; import '../secrets.dart'; import 'analysis_cache_service.dart'; +import 'gemini_exceptions.dart'; class GeminiService { @@ -349,8 +350,7 @@ $extractedText if (isLastAttempt || !is503) { // 最終試行 or 503以外のエラーはそのまま投げる if (is503) { - // [CONGESTION] マーカー付きで投げる → camera_screen でドラフト保存へ - throw Exception('[CONGESTION] AIサーバーが混雑しています。解析待ちとして保存します。'); + throw const GeminiCongestionException(); } throw Exception('AI解析エラー(Direct): $e'); }