refactor: P2/P3-Step1 — camera_screen Mixin分離 + license storeURL修正
P2: - license_screen.dart: storeUrl を store.posimai.soar-enrich.com に統一(TODO解消) P3 Step1: - camera_analysis_mixin.dart 新規作成: analyzeImages() + _performSakenowaMatching() を CameraAnalysisMixin<T extends ConsumerStatefulWidget> on ConsumerState<T> に切り出し - camera_screen.dart: 1031行 → 718行(-313行) 不要なimport 10個削除、Mixin適用、フィールド/メソッド移動 Note: Dart ライブラリプライベート制約のため Mixin の公開 API は capturedImages / quotaLockoutTime / analyzeImages(アンダースコアなし) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
ef2d940b6a
commit
aa933cf1e3
|
|
@ -0,0 +1,327 @@
|
||||||
|
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/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);
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// オンライン時: 通常の解析フロー
|
||||||
|
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);
|
||||||
|
|
||||||
|
// さけのわ自動マッチング(非同期・バックグラウンド)
|
||||||
|
// エラーが発生しても登録フローを中断しない
|
||||||
|
_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);
|
||||||
|
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')) {
|
||||||
|
setState(() {
|
||||||
|
quotaLockoutTime = DateTime.now().add(const Duration(minutes: 1));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,25 +3,16 @@ import 'dart:io'; // For File class
|
||||||
import 'package:camera/camera.dart';
|
import 'package:camera/camera.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:hive_flutter/hive_flutter.dart';
|
|
||||||
import 'package:path/path.dart' show join;
|
import 'package:path/path.dart' show join;
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
import 'package:uuid/uuid.dart';
|
import 'package:uuid/uuid.dart';
|
||||||
import 'package:gal/gal.dart';
|
import 'package:gal/gal.dart';
|
||||||
|
|
||||||
import '../providers/gemini_provider.dart';
|
|
||||||
import '../services/gemini_exceptions.dart';
|
|
||||||
import '../services/image_compression_service.dart';
|
|
||||||
import '../services/gamification_service.dart'; // Badge check
|
|
||||||
import '../services/network_service.dart';
|
|
||||||
import '../services/draft_service.dart';
|
|
||||||
import '../widgets/analyzing_dialog.dart';
|
|
||||||
import '../models/sake_item.dart';
|
|
||||||
import '../theme/app_colors.dart';
|
|
||||||
import 'package:lucide_icons/lucide_icons.dart';
|
import 'package:lucide_icons/lucide_icons.dart';
|
||||||
import 'package:image_picker/image_picker.dart'; // Gallery & Camera
|
import 'package:image_picker/image_picker.dart'; // Gallery & Camera
|
||||||
import '../providers/theme_provider.dart'; // userProfileProvider
|
|
||||||
import '../providers/sakenowa_providers.dart'; // sakenowa auto-matching
|
import '../services/image_compression_service.dart';
|
||||||
|
import '../theme/app_colors.dart';
|
||||||
|
import 'camera_analysis_mixin.dart';
|
||||||
|
|
||||||
|
|
||||||
enum CameraMode {
|
enum CameraMode {
|
||||||
|
|
@ -37,11 +28,10 @@ class CameraScreen extends ConsumerStatefulWidget {
|
||||||
ConsumerState<CameraScreen> createState() => _CameraScreenState();
|
ConsumerState<CameraScreen> createState() => _CameraScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _CameraScreenState extends ConsumerState<CameraScreen> with SingleTickerProviderStateMixin, WidgetsBindingObserver {
|
class _CameraScreenState extends ConsumerState<CameraScreen> with SingleTickerProviderStateMixin, WidgetsBindingObserver, CameraAnalysisMixin<CameraScreen> {
|
||||||
CameraController? _controller;
|
CameraController? _controller;
|
||||||
Future<void>? _initializeControllerFuture;
|
Future<void>? _initializeControllerFuture;
|
||||||
bool _isTakingPicture = false;
|
bool _isTakingPicture = false;
|
||||||
DateTime? _quotaLockoutTime;
|
|
||||||
String? _cameraError;
|
String? _cameraError;
|
||||||
|
|
||||||
double _minZoom = 1.0;
|
double _minZoom = 1.0;
|
||||||
|
|
@ -172,14 +162,12 @@ class _CameraScreenState extends ConsumerState<CameraScreen> with SingleTickerPr
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final List<String> _capturedImages = [];
|
|
||||||
|
|
||||||
Future<void> _takePicture() async {
|
Future<void> _takePicture() async {
|
||||||
// Check Quota Lockout
|
// Check Quota Lockout
|
||||||
if (_quotaLockoutTime != null) {
|
if (quotaLockoutTime != null) {
|
||||||
final remaining = _quotaLockoutTime!.difference(DateTime.now());
|
final remaining = quotaLockoutTime!.difference(DateTime.now());
|
||||||
if (remaining.isNegative) {
|
if (remaining.isNegative) {
|
||||||
setState(() => _quotaLockoutTime = null); // Reset
|
setState(() => quotaLockoutTime = null); // Reset
|
||||||
} else {
|
} else {
|
||||||
final appColors = Theme.of(context).extension<AppColors>()!;
|
final appColors = Theme.of(context).extension<AppColors>()!;
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
|
@ -296,7 +284,7 @@ class _CameraScreenState extends ConsumerState<CameraScreen> with SingleTickerPr
|
||||||
// 3. Add compressed permanent path to capture list
|
// 3. Add compressed permanent path to capture list
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_capturedImages.add(compressedPath);
|
capturedImages.add(compressedPath);
|
||||||
});
|
});
|
||||||
|
|
||||||
debugPrint('Gallery image compressed & persisted: $compressedPath');
|
debugPrint('Gallery image compressed & persisted: $compressedPath');
|
||||||
|
|
@ -305,7 +293,7 @@ class _CameraScreenState extends ConsumerState<CameraScreen> with SingleTickerPr
|
||||||
// Fallback: Use original path (legacy behavior)
|
// Fallback: Use original path (legacy behavior)
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_capturedImages.add(img.path);
|
capturedImages.add(img.path);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -318,7 +306,7 @@ class _CameraScreenState extends ConsumerState<CameraScreen> with SingleTickerPr
|
||||||
duration: const Duration(seconds: 3),
|
duration: const Duration(seconds: 3),
|
||||||
action: SnackBarAction(
|
action: SnackBarAction(
|
||||||
label: '解析する',
|
label: '解析する',
|
||||||
onPressed: _analyzeImages,
|
onPressed: analyzeImages,
|
||||||
textColor: Colors.yellow,
|
textColor: Colors.yellow,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -335,7 +323,7 @@ class _CameraScreenState extends ConsumerState<CameraScreen> with SingleTickerPr
|
||||||
}
|
}
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_capturedImages.add(imagePath);
|
capturedImages.add(imagePath);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Show Confirmation Dialog
|
// Show Confirmation Dialog
|
||||||
|
|
@ -352,7 +340,7 @@ class _CameraScreenState extends ConsumerState<CameraScreen> with SingleTickerPr
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
// Start Analysis
|
// Start Analysis
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
_analyzeImages();
|
analyzeImages();
|
||||||
},
|
},
|
||||||
style: OutlinedButton.styleFrom(
|
style: OutlinedButton.styleFrom(
|
||||||
foregroundColor: appColors.brandPrimary,
|
foregroundColor: appColors.brandPrimary,
|
||||||
|
|
@ -376,280 +364,6 @@ class _CameraScreenState extends ConsumerState<CameraScreen> with SingleTickerPr
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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);
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// オンライン時: 通常の解析フロー
|
|
||||||
if (!mounted) return;
|
|
||||||
// ignore: use_build_context_synchronously
|
|
||||||
// 直前の mounted チェックにより BuildContext の有効性は保証されている
|
|
||||||
showDialog(
|
|
||||||
context: context,
|
|
||||||
barrierDismissible: false,
|
|
||||||
builder: (context) => const AnalyzingDialog(),
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Direct Gemini Vision Analysis (OCR removed for app size reduction)
|
|
||||||
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);
|
|
||||||
|
|
||||||
// ✅ さけのわ自動マッチング(非同期・バックグラウンド)
|
|
||||||
// エラーが発生しても登録フローを中断しない
|
|
||||||
_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); // Insert at beginning
|
|
||||||
await settingsBox.put('sake_sort_order', currentOrder);
|
|
||||||
|
|
||||||
// --- v1.3 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);
|
|
||||||
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 (e) {
|
|
||||||
debugPrint('Draft save also failed: $e');
|
|
||||||
if (!mounted) return;
|
|
||||||
messenger.showSnackBar(
|
|
||||||
const SnackBar(content: Text('解析もドラフト保存も失敗しました。再試行してください。')),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Quota エラー(429)→ ロックアウト
|
|
||||||
final errStr = e.toString();
|
|
||||||
if (errStr.contains('Quota') || errStr.contains('429')) {
|
|
||||||
setState(() {
|
|
||||||
_quotaLockoutTime = DateTime.now().add(const Duration(minutes: 1));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
final appColors = Theme.of(context).extension<AppColors>()!;
|
|
||||||
messenger.showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Text('解析エラー: $e'),
|
|
||||||
duration: const Duration(seconds: 5),
|
|
||||||
backgroundColor: appColors.error,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
if (_cameraError != null) {
|
if (_cameraError != null) {
|
||||||
|
|
@ -849,22 +563,22 @@ class _CameraScreenState extends ConsumerState<CameraScreen> with SingleTickerPr
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: _quotaLockoutTime != null ? Colors.red : Colors.white,
|
color: quotaLockoutTime != null ? Colors.red : Colors.white,
|
||||||
width: 4
|
width: 4
|
||||||
),
|
),
|
||||||
color: _isTakingPicture
|
color: _isTakingPicture
|
||||||
? Colors.white.withValues(alpha: 0.5)
|
? Colors.white.withValues(alpha: 0.5)
|
||||||
: (_quotaLockoutTime != null ? Colors.red.withValues(alpha: 0.2) : Colors.transparent),
|
: (quotaLockoutTime != null ? Colors.red.withValues(alpha: 0.2) : Colors.transparent),
|
||||||
),
|
),
|
||||||
child: Center(
|
child: Center(
|
||||||
child: _quotaLockoutTime != null
|
child: quotaLockoutTime != null
|
||||||
? const Icon(LucideIcons.timer, color: Colors.white, size: 30)
|
? const Icon(LucideIcons.timer, color: Colors.white, size: 30)
|
||||||
: Container(
|
: Container(
|
||||||
height: 60,
|
height: 60,
|
||||||
width: 60,
|
width: 60,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
color: _quotaLockoutTime != null ? Colors.grey : Colors.white,
|
color: quotaLockoutTime != null ? Colors.grey : Colors.white,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -872,13 +586,13 @@ class _CameraScreenState extends ConsumerState<CameraScreen> with SingleTickerPr
|
||||||
),
|
),
|
||||||
|
|
||||||
// Right Spacer -> Analyze Button if images exist
|
// Right Spacer -> Analyze Button if images exist
|
||||||
if (_capturedImages.isNotEmpty)
|
if (capturedImages.isNotEmpty)
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: Badge(
|
icon: Badge(
|
||||||
label: Text('${_capturedImages.length}'),
|
label: Text('${capturedImages.length}'),
|
||||||
child: const Icon(LucideIcons.playCircle, color: Colors.greenAccent, size: 40),
|
child: const Icon(LucideIcons.playCircle, color: Colors.greenAccent, size: 40),
|
||||||
),
|
),
|
||||||
onPressed: _analyzeImages,
|
onPressed: analyzeImages,
|
||||||
tooltip: '解析を開始',
|
tooltip: '解析を開始',
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
|
|
@ -940,33 +654,6 @@ class _CameraScreenState extends ConsumerState<CameraScreen> with SingleTickerPr
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// ✅ さけのわ自動マッチング処理
|
|
||||||
///
|
|
||||||
/// 登録後にバックグラウンドで実行
|
|
||||||
/// エラーが発生しても登録フローを中断しない
|
|
||||||
Future<void> _performSakenowaMatching(SakeItem sakeItem) async {
|
|
||||||
try {
|
|
||||||
debugPrint('Starting sakenowa auto-matching for: ${sakeItem.displayData.displayName}');
|
|
||||||
|
|
||||||
final matchingService = ref.read(sakenowaAutoMatchingServiceProvider);
|
|
||||||
|
|
||||||
// マッチング実行(最小スコア: 0.7、自動適用: true)
|
|
||||||
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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Custom Painter for Exposure Slider
|
// Custom Painter for Exposure Slider
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,7 @@ class _LicenseScreenState extends ConsumerState<LicenseScreen> {
|
||||||
}
|
}
|
||||||
|
|
||||||
void _openStore() async {
|
void _openStore() async {
|
||||||
const storeUrl = 'https://posimai-store.soar-enrich.com'; // TODO: 実際のストアURL
|
const storeUrl = 'https://store.posimai.soar-enrich.com';
|
||||||
final uri = Uri.parse(storeUrl);
|
final uri = Uri.parse(storeUrl);
|
||||||
if (await canLaunchUrl(uri)) {
|
if (await canLaunchUrl(uri)) {
|
||||||
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue