diff --git a/lib/screens/camera_analysis_mixin.dart b/lib/screens/camera_analysis_mixin.dart new file mode 100644 index 0000000..bf74232 --- /dev/null +++ b/lib/screens/camera_analysis_mixin.dart @@ -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 on ConsumerState { + final List capturedImages = []; + DateTime? quotaLockoutTime; + + 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...'); + + 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('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 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 isDark = Theme.of(context).brightness == Brightness.dark; + final List 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()!; + messenger.showSnackBar( + SnackBar( + content: Text('解析エラー: $e'), + 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'); + } + } +} diff --git a/lib/screens/camera_screen.dart b/lib/screens/camera_screen.dart index b6470d0..6dd5280 100644 --- a/lib/screens/camera_screen.dart +++ b/lib/screens/camera_screen.dart @@ -3,25 +3,16 @@ import 'dart:io'; // For File class import 'package:camera/camera.dart'; import 'package:flutter/material.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_provider/path_provider.dart'; import 'package:uuid/uuid.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: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 { @@ -37,11 +28,10 @@ class CameraScreen extends ConsumerStatefulWidget { ConsumerState createState() => _CameraScreenState(); } -class _CameraScreenState extends ConsumerState with SingleTickerProviderStateMixin, WidgetsBindingObserver { +class _CameraScreenState extends ConsumerState with SingleTickerProviderStateMixin, WidgetsBindingObserver, CameraAnalysisMixin { CameraController? _controller; Future? _initializeControllerFuture; bool _isTakingPicture = false; - DateTime? _quotaLockoutTime; String? _cameraError; double _minZoom = 1.0; @@ -172,14 +162,12 @@ class _CameraScreenState extends ConsumerState with SingleTickerPr } } - final List _capturedImages = []; - Future _takePicture() async { // Check Quota Lockout - if (_quotaLockoutTime != null) { - final remaining = _quotaLockoutTime!.difference(DateTime.now()); + if (quotaLockoutTime != null) { + final remaining = quotaLockoutTime!.difference(DateTime.now()); if (remaining.isNegative) { - setState(() => _quotaLockoutTime = null); // Reset + setState(() => quotaLockoutTime = null); // Reset } else { final appColors = Theme.of(context).extension()!; ScaffoldMessenger.of(context).showSnackBar( @@ -296,7 +284,7 @@ class _CameraScreenState extends ConsumerState with SingleTickerPr // 3. Add compressed permanent path to capture list if (!mounted) return; setState(() { - _capturedImages.add(compressedPath); + capturedImages.add(compressedPath); }); debugPrint('Gallery image compressed & persisted: $compressedPath'); @@ -305,7 +293,7 @@ class _CameraScreenState extends ConsumerState with SingleTickerPr // Fallback: Use original path (legacy behavior) if (!mounted) return; setState(() { - _capturedImages.add(img.path); + capturedImages.add(img.path); }); } } @@ -318,7 +306,7 @@ class _CameraScreenState extends ConsumerState with SingleTickerPr duration: const Duration(seconds: 3), action: SnackBarAction( label: '解析する', - onPressed: _analyzeImages, + onPressed: analyzeImages, textColor: Colors.yellow, ), ), @@ -335,7 +323,7 @@ class _CameraScreenState extends ConsumerState with SingleTickerPr } setState(() { - _capturedImages.add(imagePath); + capturedImages.add(imagePath); }); // Show Confirmation Dialog @@ -352,7 +340,7 @@ class _CameraScreenState extends ConsumerState with SingleTickerPr onPressed: () { // Start Analysis Navigator.of(context).pop(); - _analyzeImages(); + analyzeImages(); }, style: OutlinedButton.styleFrom( foregroundColor: appColors.brandPrimary, @@ -376,280 +364,6 @@ 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...'); - - 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('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 currentOrder = (settingsBox.get('sake_sort_order') as List?) - ?.cast() ?? []; - 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 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 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()!; - messenger.showSnackBar( - SnackBar( - content: Text('解析エラー: $e'), - duration: const Duration(seconds: 5), - backgroundColor: appColors.error, - ), - ); - } - } - } - @override Widget build(BuildContext context) { if (_cameraError != null) { @@ -849,22 +563,22 @@ class _CameraScreenState extends ConsumerState with SingleTickerPr decoration: BoxDecoration( shape: BoxShape.circle, border: Border.all( - color: _quotaLockoutTime != null ? Colors.red : Colors.white, + color: quotaLockoutTime != null ? Colors.red : Colors.white, width: 4 ), color: _isTakingPicture ? 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: _quotaLockoutTime != null + child: quotaLockoutTime != null ? const Icon(LucideIcons.timer, color: Colors.white, size: 30) : Container( height: 60, width: 60, decoration: BoxDecoration( 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 with SingleTickerPr ), // Right Spacer -> Analyze Button if images exist - if (_capturedImages.isNotEmpty) + if (capturedImages.isNotEmpty) IconButton( icon: Badge( - label: Text('${_capturedImages.length}'), + label: Text('${capturedImages.length}'), child: const Icon(LucideIcons.playCircle, color: Colors.greenAccent, size: 40), ), - onPressed: _analyzeImages, + onPressed: analyzeImages, tooltip: '解析を開始', ) else @@ -940,33 +654,6 @@ class _CameraScreenState extends ConsumerState with SingleTickerPr ); } - /// ✅ さけのわ自動マッチング処理 - /// - /// 登録後にバックグラウンドで実行 - /// エラーが発生しても登録フローを中断しない - Future _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 diff --git a/lib/screens/license_screen.dart b/lib/screens/license_screen.dart index 8242c46..10d4d25 100644 --- a/lib/screens/license_screen.dart +++ b/lib/screens/license_screen.dart @@ -54,7 +54,7 @@ class _LicenseScreenState extends ConsumerState { } 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); if (await canLaunchUrl(uri)) { await launchUrl(uri, mode: LaunchMode.externalApplication);