ponshu-room-lite/lib/screens/camera_analysis_mixin.dart

411 lines
16 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<T extends ConsumerStatefulWidget> on ConsumerState<T> {
final List<String> capturedImages = [];
Future<void> 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<AppColors>()!;
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<SakeItem>('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<String> currentOrder = (settingsBox.get('sake_sort_order') as List<dynamic>?)
?.cast<String>() ?? [];
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<dynamic> 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<Widget> 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<void> _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');
}
}
}