diff --git a/.gitignore b/.gitignore index 34ca363..a937a0a 100644 --- a/.gitignore +++ b/.gitignore @@ -78,6 +78,7 @@ Desktop.ini *.tmp *.bak analyze_output*.txt +build_log_*.txt # Deprecated build scripts (API keys hardcoded - use build_consumer_apks.ps1 instead) build_all_apks.ps1 diff --git a/lib/main.dart b/lib/main.dart index 838a5ef..6fbaf1b 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -10,10 +10,6 @@ import 'screens/main_screen.dart'; import 'screens/license_screen.dart'; import 'services/migration_service.dart'; -/// ビルド時Pro解放フラグ(現在未使用 — 実行時ライセンスはisProProviderで管理) -/// 将来的に削除予定。isProProvider (license_provider.dart) を使うこと。 -const bool isProVersion = bool.fromEnvironment('IS_PRO_VERSION', defaultValue: false); - /// 店舗向けビルドかどうかを判定するビルド時フラグ /// /// ビルドコマンド: @@ -57,12 +53,9 @@ void main() async { : 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_version', currentMigrationVersion); await box.put('migration_completed', true); // 旧フラグも維持(後方互換) - } else { - debugPrint('✅ Migration up to date (v$storedVersion). Skipping.'); } // ✅ AI解析キャッシュは使うときに初期化する(Lazy initialization) diff --git a/lib/providers/gemini_provider.dart b/lib/providers/gemini_provider.dart new file mode 100644 index 0000000..fe15d02 --- /dev/null +++ b/lib/providers/gemini_provider.dart @@ -0,0 +1,17 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../services/gemini_service.dart'; + +/// GeminiService のシングルトンプロバイダー +/// +/// アプリ全体で同一インスタンスを共有する。 +/// レート制限の状態(_lastApiCallTime)がインスタンス間で共有されるため、 +/// 複数画面から同時に呼び出しても連打防止が正しく機能する。 +/// +/// 使用例: +/// ```dart +/// final geminiService = ref.read(geminiServiceProvider); +/// final result = await geminiService.analyzeSakeLabel(paths); +/// ``` +final geminiServiceProvider = Provider((ref) { + return GeminiService(); +}); diff --git a/lib/screens/camera_screen.dart b/lib/screens/camera_screen.dart index c47d9ac..21bf574 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 '../providers/gemini_provider.dart'; import '../services/gemini_exceptions.dart'; import '../services/image_compression_service.dart'; import '../services/gamification_service.dart'; // Badge check @@ -372,19 +373,21 @@ class _CameraScreenState extends ConsumerState with SingleTickerPr Future _analyzeImages() async { if (_capturedImages.isEmpty) return; + // async gap 前に context 依存オブジェクトをキャプチャ + final messenger = ScaffoldMessenger.of(context); + final navigator = Navigator.of(context); + final isOnline = await NetworkService.isOnline(); if (!isOnline) { // オフライン時: Draft として保存 - debugPrint('📴 Offline detected: Saving as draft...'); + debugPrint('Offline detected: Saving as draft...'); try { - // 🔧 FIX: 複数画像をすべて保存 await DraftService.saveDraft(_capturedImages); if (!mounted) return; - // ユーザーに通知 - ScaffoldMessenger.of(context).showSnackBar( + messenger.showSnackBar( const SnackBar( content: Column( mainAxisSize: MainAxisSize.min, @@ -407,13 +410,12 @@ class _CameraScreenState extends ConsumerState with SingleTickerPr ), ); - // カメラ画面を閉じる - Navigator.of(context).pop(); + navigator.pop(); return; } catch (e) { - debugPrint('⚠️ Draft save error: $e'); + debugPrint('Draft save error: $e'); if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( + messenger.showSnackBar( SnackBar(content: Text('Draft保存エラー: $e')), ); return; @@ -421,7 +423,6 @@ class _CameraScreenState extends ConsumerState with SingleTickerPr } // オンライン時: 通常の解析フロー - // Show AnalyzingDialog if (!mounted) return; showDialog( context: context, @@ -432,7 +433,7 @@ class _CameraScreenState extends ConsumerState with SingleTickerPr try { // Direct Gemini Vision Analysis (OCR removed for app size reduction) debugPrint('📸 Starting Gemini Vision Direct Analysis for ${_capturedImages.length} images'); - final geminiService = GeminiService(); + final geminiService = ref.read(geminiServiceProvider); final result = await geminiService.analyzeSakeLabel(_capturedImages); // Create SakeItem (Schema v2.0) @@ -504,11 +505,8 @@ class _CameraScreenState extends ConsumerState with SingleTickerPr if (!mounted) return; - // Close Dialog - Navigator.of(context).pop(); - - // Close Camera Screen (Return to Home) - Navigator.of(context).pop(); + navigator.pop(); // Close AnalyzingDialog + navigator.pop(); // Close Camera Screen (Return to Home) // Success Message (with EXP/Level Up/Badge info) final isDark = Theme.of(context).brightness == Brightness.dark; @@ -554,28 +552,28 @@ class _CameraScreenState extends ConsumerState with SingleTickerPr } } - ScaffoldMessenger.of(context).showSnackBar( + messenger.showSnackBar( SnackBar( content: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: messageWidgets, ), - duration: Duration(seconds: newBadges.isNotEmpty ? 6 : 4), // Longer for badges + duration: Duration(seconds: newBadges.isNotEmpty ? 6 : 4), ), ); } catch (e) { if (mounted) { - Navigator.of(context).pop(); // Close AnalyzingDialog + navigator.pop(); // Close AnalyzingDialog // AIサーバー混雑(503)→ ドラフト保存してオフライン時と同じ扱いに if (e is GeminiCongestionException) { try { await DraftService.saveDraft(_capturedImages); if (!mounted) return; - Navigator.of(context).pop(); // Close camera screen - ScaffoldMessenger.of(context).showSnackBar( + navigator.pop(); // Close camera screen + messenger.showSnackBar( const SnackBar( content: Column( mainAxisSize: MainAxisSize.min, @@ -598,9 +596,8 @@ class _CameraScreenState extends ConsumerState with SingleTickerPr ), ); } catch (_) { - // ドラフト保存も失敗した場合のみエラー表示 if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( + messenger.showSnackBar( const SnackBar(content: Text('解析もドラフト保存も失敗しました。再試行してください。')), ); } @@ -616,7 +613,7 @@ class _CameraScreenState extends ConsumerState with SingleTickerPr } final appColors = Theme.of(context).extension()!; - ScaffoldMessenger.of(context).showSnackBar( + messenger.showSnackBar( SnackBar( content: Text('解析エラー: $e'), duration: const Duration(seconds: 5), diff --git a/lib/screens/dev_menu_screen.dart b/lib/screens/dev_menu_screen.dart index 75d2584..d8d7899 100644 --- a/lib/screens/dev_menu_screen.dart +++ b/lib/screens/dev_menu_screen.dart @@ -7,6 +7,7 @@ import 'package:hive_flutter/hive_flutter.dart'; import '../models/sake_item.dart'; import '../providers/sake_list_provider.dart'; import '../services/gemini_service.dart'; +import '../providers/gemini_provider.dart'; class DevMenuScreen extends ConsumerWidget { const DevMenuScreen({super.key}); @@ -198,7 +199,7 @@ class DevMenuScreen extends ConsumerWidget { final box = Hive.box('sake_items'); try { - final gemini = GeminiService(); + final gemini = ref.read(geminiServiceProvider); for (final item in targets) { if (item.displayData.imagePaths.isEmpty) continue; diff --git a/lib/screens/sake_detail_screen.dart b/lib/screens/sake_detail_screen.dart index d33e11a..136915b 100644 --- a/lib/screens/sake_detail_screen.dart +++ b/lib/screens/sake_detail_screen.dart @@ -5,6 +5,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:lucide_icons/lucide_icons.dart'; import '../models/sake_item.dart'; import '../services/gemini_service.dart'; +import '../providers/gemini_provider.dart'; import '../services/sake_recommendation_service.dart'; import '../widgets/analyzing_dialog.dart'; import '../widgets/sake_3d_carousel_with_reason.dart'; @@ -331,11 +332,8 @@ class _SakeDetailScreenState extends ConsumerState { 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) { @@ -348,15 +346,13 @@ class _SakeDetailScreenState extends ConsumerState { setState(() => _isAnalyzing = true); try { - // ignore: use_build_context_synchronously showDialog( - context: context, // ignore: use_build_context_synchronously + context: context, barrierDismissible: false, builder: (context) => const AnalyzingDialog(), ); - final geminiService = GeminiService(); - // forceRefresh: true でキャッシュを無視して再解析 + final geminiService = ref.read(geminiServiceProvider); final result = await geminiService.analyzeSakeLabel(existingPaths, forceRefresh: true); final newItem = _sake.copyWith( diff --git a/lib/screens/splash_screen.dart b/lib/screens/splash_screen.dart deleted file mode 100644 index 34ae222..0000000 --- a/lib/screens/splash_screen.dart +++ /dev/null @@ -1,121 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:hive_flutter/hive_flutter.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import '../models/sake_item.dart'; -import '../models/user_profile.dart'; -import '../models/menu_settings.dart'; -import '../services/migration_service.dart'; -import 'main_screen.dart'; - -class SplashScreen extends ConsumerStatefulWidget { - const SplashScreen({super.key}); - - @override - ConsumerState createState() => _SplashScreenState(); -} - -class _SplashScreenState extends ConsumerState { - @override - void initState() { - super.initState(); - _initializeData(); - } - - Future _initializeData() async { - // Artificial delay for branding (optional, keep it minimal) - // await Future.delayed(const Duration(milliseconds: 500)); - - try { - // Initialize Hive - await Hive.initFlutter(); - - // Register Adapters - Hive.registerAdapter(SakeItemAdapter()); - Hive.registerAdapter(UserProfileAdapter()); - Hive.registerAdapter(MenuSettingsAdapter()); - // Phase 0 New Adapters - Hive.registerAdapter(DisplayDataAdapter()); - Hive.registerAdapter(HiddenSpecsAdapter()); - Hive.registerAdapter(UserDataAdapter()); - Hive.registerAdapter(GamificationAdapter()); - Hive.registerAdapter(MetadataAdapter()); - Hive.registerAdapter(ItemTypeAdapter()); - - // Open all boxes (Parallel Execution) - final results = await Future.wait([ - Hive.openBox('settings'), - Hive.openBox('user_profile'), - Hive.openBox('sake_items'), - Hive.openBox('menu_settings'), - ]); - - final settingsBox = results[0]; - - // Run Phase 0 Migration (Only once) - final migrationCompleted = settingsBox.get('migration_completed', defaultValue: false); - if (!migrationCompleted) { - debugPrint('🚀 Running MigrationService...'); - await MigrationService.runMigration(); - await settingsBox.put('migration_completed', true); - } else { - debugPrint('✅ Migration already completed. Skipping.'); - } - - if (!mounted) return; - - // Navigate to MainScreen - Navigator.of(context).pushReplacement( - PageRouteBuilder( - pageBuilder: (context, animation, secondaryAnimation) => const MainScreen(), - transitionsBuilder: (context, animation, secondaryAnimation, child) { - return FadeTransition(opacity: animation, child: child); - }, - transitionDuration: const Duration(milliseconds: 500), - ), - ); - - } catch (e) { - debugPrint('❌ Initialization Error: $e'); - // Show error UI or retry logic - } - } - - @override - Widget build(BuildContext context) { - return Scaffold( - backgroundColor: Colors.white, // Match brand color - body: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - // App Logo - const Text( - '🍶', - style: TextStyle(fontSize: 80), - ), - const SizedBox(height: 24), - Text( - 'Ponshu Room', - style: TextStyle( - fontSize: 24, - fontWeight: FontWeight.bold, - color: Colors.grey[800], - letterSpacing: 1.2, - ), - ), - const SizedBox(height: 48), - // Subtle loading indicator - SizedBox( - width: 24, - height: 24, - child: CircularProgressIndicator( - strokeWidth: 2, - color: Colors.grey[400], - ), - ), - ], - ), - ), - ); - } -} diff --git a/lib/services/analysis_cache_service.dart b/lib/services/analysis_cache_service.dart index e78646e..f4e4e99 100644 --- a/lib/services/analysis_cache_service.dart +++ b/lib/services/analysis_cache_service.dart @@ -68,7 +68,8 @@ class AnalysisCacheService { /// キャッシュから取得 /// - /// 戻り値: キャッシュがあれば解析結果、なければnull + /// 戻り値: キャッシュがあれば解析結果、なければ null + /// タイムスタンプ付き新形式と旧形式(直接 JSON)の両方に対応 static Future getCached(String imageHash) async { await init(); if (_box == null) return null; @@ -76,33 +77,43 @@ class AnalysisCacheService { try { final jsonString = _box!.get(imageHash); if (jsonString == null) { - debugPrint('🔍 Cache MISS: $imageHash'); + debugPrint('Cache MISS: $imageHash'); return null; } - final jsonMap = jsonDecode(jsonString) as Map; - debugPrint('✅ Cache HIT: ${jsonMap['name'] ?? 'Unknown'} ($imageHash)'); - return SakeAnalysisResult.fromJson(jsonMap); + final decoded = jsonDecode(jsonString) as Map; + + // 新形式: { data: {...}, savedAt: "..." } + if (decoded.containsKey('data') && decoded.containsKey('savedAt')) { + debugPrint('Cache HIT: ${decoded['data']['name'] ?? 'Unknown'}'); + return SakeAnalysisResult.fromJson(decoded['data'] as Map); + } + + // 旧形式(後方互換): 直接 SakeAnalysisResult の JSON + debugPrint('Cache HIT (legacy): ${decoded['name'] ?? 'Unknown'}'); + return SakeAnalysisResult.fromJson(decoded); } catch (e) { - debugPrint('⚠️ Cache read error: $e'); + debugPrint('Cache read error: $e'); return null; } } /// キャッシュに保存 /// - /// 解析結果をJSON化してHiveに永続化 + /// 解析結果を JSON 化してタイムスタンプ付きで Hive に永続化 static Future saveCache(String imageHash, SakeAnalysisResult result) async { await init(); if (_box == null) return; try { - final jsonMap = result.toJson(); - final jsonString = jsonEncode(jsonMap); - await _box!.put(imageHash, jsonString); - debugPrint('💾 Cache SAVED: ${result.name ?? 'Unknown'} ($imageHash)'); + final entry = { + 'data': result.toJson(), + 'savedAt': DateTime.now().toIso8601String(), + }; + await _box!.put(imageHash, jsonEncode(entry)); + debugPrint('Cache saved: ${result.name ?? 'Unknown'}'); } catch (e) { - debugPrint('⚠️ Cache save error: $e'); + debugPrint('Cache save error: $e'); } } @@ -122,13 +133,37 @@ class AnalysisCacheService { return _box?.length ?? 0; } - /// キャッシュの有効期限チェック(将来実装) + /// 30日以上経過したキャッシュエントリを削除 /// - /// 現在は永続キャッシュだが、将来的に有効期限を設定する場合: - /// - 30日経過したキャッシュは削除 - /// - 日本酒の仕様変更(リニューアル)に対応 - static Future cleanupExpired() async { - // TODO: キャッシュにタイムスタンプを追加し、30日以上古いエントリを削除する + /// アプリ起動時またはバックグラウンドで呼び出す。 + /// 旧形式エントリ(タイムスタンプなし)は対象外として保持する。 + static Future cleanupExpired({int ttlDays = 30}) async { + await init(); + if (_box == null) return; + + final cutoff = DateTime.now().subtract(Duration(days: ttlDays)); + final keysToDelete = []; + + for (final key in _box!.keys) { + try { + final jsonString = _box!.get(key as String); + if (jsonString == null) continue; + final decoded = jsonDecode(jsonString) as Map; + if (!decoded.containsKey('savedAt')) continue; // 旧形式は対象外 + + final savedAt = DateTime.tryParse(decoded['savedAt'] as String? ?? ''); + if (savedAt != null && savedAt.isBefore(cutoff)) { + keysToDelete.add(key); + } + } catch (_) { + // 読み込みエラーのエントリは無視 + } + } + + if (keysToDelete.isNotEmpty) { + await _box!.deleteAll(keysToDelete); + debugPrint('Cache cleanup: ${keysToDelete.length} expired entries removed'); + } } // ============================================ @@ -162,25 +197,29 @@ class AnalysisCacheService { /// 銘柄名をインデックスに登録 /// - /// 解析結果のキャッシュ保存後に呼び出す - /// 既にインデックスがある場合は上書きしない(最初の結果を優先) - static Future registerBrandIndex(String? brandName, String imageHash) async { + /// [forceUpdate] = true のとき(再解析など)は既存エントリを上書きする。 + /// false のときは最初の結果を優先し、上書きしない。 + static Future registerBrandIndex( + String? brandName, + String imageHash, { + bool forceUpdate = false, + }) async { if (brandName == null || brandName.isEmpty) return; await init(); if (_brandIndexBox == null) return; try { final normalized = _normalizeBrandName(brandName); + final exists = _brandIndexBox!.containsKey(normalized); - // 既にインデックスがある場合は上書きしない(最初の結果を優先) - if (!_brandIndexBox!.containsKey(normalized)) { + if (!exists || forceUpdate) { await _brandIndexBox!.put(normalized, imageHash); - debugPrint('📝 Brand index registered: $brandName → $imageHash'); + debugPrint('Brand index ${exists ? "updated" : "registered"}: $brandName'); } else { - debugPrint('ℹ️ Brand index already exists: $brandName (skipped)'); + debugPrint('Brand index already exists: $brandName (skipped)'); } } catch (e) { - debugPrint('⚠️ Brand index registration error: $e'); + debugPrint('Brand index registration error: $e'); } } diff --git a/lib/services/gemini_service.dart b/lib/services/gemini_service.dart index be2cf7f..c08c86f 100644 --- a/lib/services/gemini_service.dart +++ b/lib/services/gemini_service.dart @@ -284,8 +284,12 @@ class GeminiService { if (imagePaths.isNotEmpty) { final imageHash = await AnalysisCacheService.computeCombinedHash(imagePaths); await AnalysisCacheService.saveCache(imageHash, result); - // 4. 銘柄名インデックスに登録(v1.0.15: チャート一貫性向上) - await AnalysisCacheService.registerBrandIndex(result.name, imageHash); + // 4. 銘柄名インデックスに登録(forceRefresh 時は誤認識結果を上書き) + await AnalysisCacheService.registerBrandIndex( + result.name, + imageHash, + forceUpdate: forceRefresh, + ); } if (attempt > 0) debugPrint('Succeeded on attempt $attempt (model: $modelName)'); diff --git a/pubspec.yaml b/pubspec.yaml index d64c9b5..c0c03f6 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 1.0.30+37 +version: 1.0.31+38 environment: sdk: ^3.10.1 @@ -97,8 +97,8 @@ flutter_launcher_icons: web: generate: true image_path: "assets/images/app_icon.png" - background_color: "#hex_code" - theme_color: "#hex_code" + background_color: "#FDFAF5" + theme_color: "#4A3B32" windows: generate: true image_path: "assets/images/app_icon.png" diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 4691187..616f14a 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -6,15 +6,24 @@ #include "generated_plugin_registrant.h" +#include #include #include #include +#include +#include void RegisterPlugins(flutter::PluginRegistry* registry) { + ConnectivityPlusWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin")); FileSelectorWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("FileSelectorWindows")); GalPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("GalPluginCApi")); PrintingPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("PrintingPlugin")); + SharePlusWindowsPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("SharePlusWindowsPluginCApi")); + UrlLauncherWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("UrlLauncherWindows")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 4441a52..57ebdd9 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -3,9 +3,12 @@ # list(APPEND FLUTTER_PLUGIN_LIST + connectivity_plus file_selector_windows gal printing + share_plus + url_launcher_windows ) list(APPEND FLUTTER_FFI_PLUGIN_LIST