import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:hive_flutter/hive_flutter.dart'; import 'package:uuid/uuid.dart'; import 'package:lucide_icons/lucide_icons.dart'; import '../models/sake_item.dart'; import '../providers/gemini_provider.dart'; import '../providers/quota_lockout_provider.dart'; import '../providers/sakenowa_providers.dart'; import '../providers/theme_provider.dart'; import '../services/api_usage_service.dart'; import '../services/draft_service.dart'; import '../services/gamification_service.dart'; import '../services/gemini_exceptions.dart'; import '../services/network_service.dart'; import '../theme/app_colors.dart'; import '../widgets/analyzing_dialog.dart'; /// カメラ解析ロジックを CameraScreen の State から切り出した Mixin。 /// /// 責務: /// - capturedImages / quotaLockoutTime の保持(先頭 _ は不可: 別ライブラリの State から参照できない) /// - analyzeImages() : オンライン/オフライン分岐・Gemini呼び出し・Hive保存・Gamification /// - _performSakenowaMatching() : バックグラウンドさけのわ自動マッチング mixin CameraAnalysisMixin on ConsumerState { final List capturedImages = []; Future analyzeImages() async { if (capturedImages.isEmpty) return; // async gap 前に context 依存オブジェクトをキャプチャ final messenger = ScaffoldMessenger.of(context); final navigator = Navigator.of(context); final appColors = Theme.of(context).extension()!; final isOnline = await NetworkService.isOnline(); if (!isOnline) { // オフライン時: Draft として保存 debugPrint('Offline detected: Saving as draft...'); try { await DraftService.saveDraft(capturedImages, reason: DraftReason.offline); if (!mounted) return; messenger.showSnackBar( SnackBar( content: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Icon(LucideIcons.wifiOff, color: Colors.white, size: 16), const SizedBox(width: 8), const Text('オフライン検知', style: TextStyle(fontWeight: FontWeight.bold)), ], ), const SizedBox(height: 4), const Text('写真を「解析待ち」として保存しました。'), const Text('オンライン復帰後、ホーム画面から解析できます。'), ], ), duration: const Duration(seconds: 5), backgroundColor: appColors.warning, ), ); navigator.pop(); return; } catch (e) { debugPrint('Draft save error: $e'); if (!mounted) return; messenger.showSnackBar( const SnackBar(content: Text('写真の一時保存に失敗しました。再度お試しください。')), ); return; } } // クォータ事前チェック(日次上限 20回/日、UTC 08:00 リセット) final isQuotaExhausted = await ApiUsageService.isExhausted(); if (isQuotaExhausted) { debugPrint('Quota exhausted: Saving as draft...'); try { await DraftService.saveDraft(capturedImages, reason: DraftReason.quotaLimit); if (!mounted) return; final resetTime = ApiUsageService.getNextResetTime(); final resetStr = '${resetTime.hour}:${resetTime.minute.toString().padLeft(2, '0')}'; messenger.showSnackBar( SnackBar( content: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ const Icon(LucideIcons.zap, color: Colors.white, size: 16), const SizedBox(width: 8), const Text('本日のAI解析上限(20回)に達しました', style: TextStyle(fontWeight: FontWeight.bold)), ], ), const SizedBox(height: 4), const Text('写真を「解析待ち」として保存しました。'), Text('$resetStr 以降にホーム画面から解析できます。'), ], ), duration: const Duration(seconds: 6), backgroundColor: appColors.warning, ), ); navigator.pop(); // カメラ画面を閉じてホームへ } catch (e) { debugPrint('Draft save error (quota): $e'); if (!mounted) return; messenger.showSnackBar( const SnackBar(content: Text('写真の一時保存に失敗しました。再度お試しください。')), ); } return; } // オンライン時: 通常の解析フロー if (!mounted) return; // ignore: use_build_context_synchronously // 直前の mounted チェックにより BuildContext の有効性は保証されている showDialog( context: context, barrierDismissible: false, builder: (context) => const AnalyzingDialog(), ); try { debugPrint('Starting Gemini Vision Direct Analysis for ${capturedImages.length} images'); final geminiService = ref.read(geminiServiceProvider); final result = await geminiService.analyzeSakeLabel(capturedImages); // Create SakeItem (Schema v2.0) final sakeItem = SakeItem( id: const Uuid().v4(), displayData: DisplayData( name: result.name ?? '不明な日本酒', brewery: result.brand ?? '不明', prefecture: result.prefecture ?? '不明', catchCopy: result.catchCopy, imagePaths: List.from(capturedImages), rating: null, ), hiddenSpecs: HiddenSpecs( description: result.description, tasteStats: result.tasteStats, flavorTags: result.flavorTags, type: result.type, alcoholContent: result.alcoholContent, polishingRatio: result.polishingRatio, sakeMeterValue: result.sakeMeterValue, riceVariety: result.riceVariety, yeast: result.yeast, manufacturingYearMonth: result.manufacturingYearMonth, ), metadata: Metadata( createdAt: DateTime.now(), aiConfidence: result.confidenceScore, ), ); // Save to Hive final box = Hive.box('sake_items'); await box.add(sakeItem); // API 使用回数をカウントアップ(キャッシュヒット時は実際の API 呼び出しなし) if (!result.isFromCache) { await ApiUsageService.increment(); ref.invalidate(apiUsageCountProvider); } // さけのわ自動マッチング(非同期・バックグラウンド) // エラーが発生しても登録フローを中断しない _performSakenowaMatching(sakeItem).catchError((error) { debugPrint('Sakenowa auto-matching failed (non-critical): $error'); }); // Prepend new item to sort order so it appears at the top final settingsBox = Hive.box('settings'); final List currentOrder = (settingsBox.get('sake_sort_order') as List?) ?.cast() ?? []; currentOrder.insert(0, sakeItem.id); await settingsBox.put('sake_sort_order', currentOrder); // --- Gamification Hook --- // キャッシュヒット結果にはEXP付与しない(同一画像の重複スキャン対策) int expGained = 0; int newLevel = 0; bool isLevelUp = false; List newBadges = []; if (!result.isFromCache) { final userProfileState = ref.read(userProfileProvider); final prevLevel = userProfileState.level; expGained = 10; await ref.read(userProfileProvider.notifier).updateTotalExp(userProfileState.totalExp + expGained); newBadges = await GamificationService.checkAndUnlockBadges(ref); final updatedProfile = ref.read(userProfileProvider); newLevel = updatedProfile.level; isLevelUp = newLevel > prevLevel; debugPrint('Gamification: EXP +$expGained (Total: ${updatedProfile.totalExp}) Level: $prevLevel -> $newLevel'); } else { debugPrint('Cache hit: EXP not awarded (same image re-scanned)'); } debugPrint('Saved to Hive: ${sakeItem.displayData.displayName} (ID: ${sakeItem.id})'); debugPrint('Total items in box: ${box.length}'); // 解析完了後に画像リストをクリア(次の撮影セッション用) setState(() => capturedImages.clear()); if (!mounted) return; navigator.pop(); // Close AnalyzingDialog navigator.pop(); // Close Camera Screen (Return to Home) // Success Message final List messageWidgets = [ Text('${sakeItem.displayData.displayName} を登録しました!'), ]; if (result.isFromCache) { messageWidgets.add(const SizedBox(height: 4)); messageWidgets.add(Text( '※ 解析済みの結果を使用(経験値なし)', style: TextStyle(fontSize: 12, color: appColors.textTertiary), )); } else { messageWidgets.add(const SizedBox(height: 4)); messageWidgets.add(Row( children: [ Icon(LucideIcons.sparkles, color: appColors.brandAccent, size: 16), const SizedBox(width: 8), Text( '経験値 +$expGained GET!${isLevelUp ? " (Level UP!)" : ""}', style: TextStyle( fontWeight: FontWeight.bold, color: appColors.brandAccent, ), ), ], )); } if (newBadges.isNotEmpty) { messageWidgets.add(const SizedBox(height: 8)); for (var badge in newBadges) { messageWidgets.add( Row( children: [ Text(badge.icon, style: const TextStyle(fontSize: 16)), const SizedBox(width: 8), Text( 'バッジ獲得: ${badge.name}', style: TextStyle( fontWeight: FontWeight.bold, color: appColors.success, ), ), ], ), ); } } messenger.showSnackBar( SnackBar( content: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: messageWidgets, ), duration: Duration(seconds: newBadges.isNotEmpty ? 6 : 4), ), ); } catch (e) { if (mounted) { navigator.pop(); // Close AnalyzingDialog // AIサーバー混雑(503)→ ドラフト保存してオフライン時と同じ扱いに if (e is GeminiCongestionException) { try { await DraftService.saveDraft(capturedImages, reason: DraftReason.congestion); if (!mounted) return; navigator.pop(); // Close camera screen messenger.showSnackBar( SnackBar( content: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ const Row( children: [ Icon(LucideIcons.cloudOff, color: Colors.white, size: 16), SizedBox(width: 8), Text('AIサーバー混雑', style: TextStyle(fontWeight: FontWeight.bold)), ], ), const SizedBox(height: 4), const Text('写真を「解析待ち」として保存しました。'), const Text('時間をおいてホーム画面から解析できます。'), ], ), duration: const Duration(seconds: 5), backgroundColor: appColors.warning, ), ); } catch (draftError) { debugPrint('Draft save also failed: $draftError'); if (!mounted) return; messenger.showSnackBar( const SnackBar(content: Text('解析もドラフト保存も失敗しました。再試行してください。')), ); } return; } // Quota エラー(429)→ ドラフト保存してカメラを閉じる final errStr = e.toString(); if (errStr.contains('Quota') || errStr.contains('429')) { try { await DraftService.saveDraft(capturedImages, reason: DraftReason.quotaLimit); ref.read(quotaLockoutProvider.notifier).set(DateTime.now().add(const Duration(minutes: 1))); if (!mounted) return; navigator.pop(); // カメラ画面を閉じる final resetTime = ApiUsageService.getNextResetTime(); final resetStr = '${resetTime.hour}:${resetTime.minute.toString().padLeft(2, '0')}'; messenger.showSnackBar( SnackBar( content: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ const Row( children: [ Icon(LucideIcons.zap, color: Colors.white, size: 16), SizedBox(width: 8), Text('本日のAI解析上限(20回)に達しました', style: TextStyle(fontWeight: FontWeight.bold)), ], ), const SizedBox(height: 4), const Text('写真を「解析待ち」として保存しました。'), Text('$resetStr 以降にホーム画面から解析できます。'), ], ), duration: const Duration(seconds: 6), backgroundColor: appColors.warning, ), ); } catch (draftError) { debugPrint('Draft save failed after 429: $draftError'); if (!mounted) return; messenger.showSnackBar( const SnackBar(content: Text('解析もドラフト保存も失敗しました。再試行してください。')), ); } return; } debugPrint('Analysis error: $e'); messenger.showSnackBar( SnackBar( content: const Text('解析に失敗しました。時間をおいて再試行してください。'), duration: const Duration(seconds: 5), backgroundColor: appColors.error, ), ); } } } /// さけのわ自動マッチング処理 /// /// 登録後にバックグラウンドで実行。 /// エラーが発生しても登録フローを中断しない。 Future _performSakenowaMatching(SakeItem sakeItem) async { try { debugPrint('Starting sakenowa auto-matching for: ${sakeItem.displayData.displayName}'); final matchingService = ref.read(sakenowaAutoMatchingServiceProvider); final result = await matchingService.matchSake( sakeItem: sakeItem, minScore: 0.7, autoApply: true, ); if (result.hasMatch) { debugPrint('Sakenowa matching successful: ${result.brand?.name} / score=${result.score.toStringAsFixed(2)} confident=${result.isConfident}'); } else { debugPrint('No sakenowa match found (score: ${result.score.toStringAsFixed(2)})'); } } catch (e, stackTrace) { debugPrint('Sakenowa auto-matching error: $e'); debugPrint('Stack trace: $stackTrace'); } } }