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

412 lines
16 KiB
Dart
Raw Normal View History

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/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 = [];
DateTime? quotaLockoutTime;
Future<void> 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...');
try {
await DraftService.saveDraft(capturedImages, reason: DraftReason.offline);
if (!mounted) return;
messenger.showSnackBar(
const SnackBar(
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(LucideIcons.wifiOff, color: Colors.orange, size: 16),
SizedBox(width: 8),
Text('オフライン検知', style: TextStyle(fontWeight: FontWeight.bold)),
],
),
SizedBox(height: 4),
Text('写真を「解析待ち」として保存しました。'),
Text('オンライン復帰後、ホーム画面から解析できます。'),
],
),
duration: Duration(seconds: 5),
backgroundColor: Colors.orange,
),
);
navigator.pop();
return;
} catch (e) {
debugPrint('Draft save error: $e');
if (!mounted) return;
messenger.showSnackBar(
SnackBar(content: Text('Draft保存エラー: $e')),
);
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: [
const Row(
children: [
Icon(LucideIcons.zap, color: Colors.orange, 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: Colors.orange,
),
);
navigator.pop(); // カメラ画面を閉じてホームへ
} catch (e) {
debugPrint('Draft save error (quota): $e');
if (!mounted) return;
messenger.showSnackBar(
SnackBar(content: Text('保存エラー: $e')),
);
}
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 isDark = Theme.of(context).brightness == Brightness.dark;
final List<Widget> messageWidgets = [
Text('${sakeItem.displayData.displayName} を登録しました!'),
];
if (result.isFromCache) {
messageWidgets.add(const SizedBox(height: 4));
messageWidgets.add(const Text(
'※ 解析済みの結果を使用(経験値なし)',
style: TextStyle(fontSize: 12, color: Colors.grey),
));
} else {
messageWidgets.add(const SizedBox(height: 4));
messageWidgets.add(Row(
children: [
Icon(LucideIcons.sparkles,
color: isDark ? Colors.yellow.shade300 : Colors.yellow,
size: 16),
const SizedBox(width: 8),
Text(
'経験値 +$expGained GET!${isLevelUp ? " (Level UP!)" : ""}',
style: TextStyle(
fontWeight: FontWeight.bold,
color: isDark ? Colors.yellow.shade200 : Colors.yellowAccent,
),
),
],
));
}
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: isDark ? Colors.green.shade300 : Colors.greenAccent,
),
),
],
),
);
}
}
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(
const SnackBar(
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(LucideIcons.cloudOff, color: Colors.white, size: 16),
SizedBox(width: 8),
Text('AIサーバー混雑', style: TextStyle(fontWeight: FontWeight.bold)),
],
),
SizedBox(height: 4),
Text('写真を「解析待ち」として保存しました。'),
Text('時間をおいてホーム画面から解析できます。'),
],
),
duration: Duration(seconds: 5),
backgroundColor: Colors.orange,
),
);
} 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);
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.orange, 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: Colors.orange,
),
);
} catch (draftError) {
debugPrint('Draft save failed after 429: $draftError');
if (!mounted) return;
messenger.showSnackBar(
const SnackBar(content: Text('解析もドラフト保存も失敗しました。再試行してください。')),
);
}
return;
}
final appColors = Theme.of(context).extension<AppColors>()!;
messenger.showSnackBar(
SnackBar(
content: Text('解析エラー: $e'),
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');
}
}
}