2026-04-16 04:20:53 +00:00
|
|
|
|
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';
|
2026-04-18 05:18:27 +00:00
|
|
|
|
import '../providers/quota_lockout_provider.dart';
|
2026-04-16 04:20:53 +00:00
|
|
|
|
import '../providers/sakenowa_providers.dart';
|
|
|
|
|
|
import '../providers/theme_provider.dart';
|
2026-04-16 08:58:00 +00:00
|
|
|
|
import '../services/api_usage_service.dart';
|
2026-04-16 04:20:53 +00:00
|
|
|
|
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);
|
2026-04-17 14:48:32 +00:00
|
|
|
|
final appColors = Theme.of(context).extension<AppColors>()!;
|
2026-04-16 04:20:53 +00:00
|
|
|
|
|
|
|
|
|
|
final isOnline = await NetworkService.isOnline();
|
|
|
|
|
|
if (!isOnline) {
|
|
|
|
|
|
// オフライン時: Draft として保存
|
|
|
|
|
|
debugPrint('Offline detected: Saving as draft...');
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
2026-04-16 08:58:00 +00:00
|
|
|
|
await DraftService.saveDraft(capturedImages, reason: DraftReason.offline);
|
2026-04-16 04:20:53 +00:00
|
|
|
|
|
|
|
|
|
|
if (!mounted) return;
|
|
|
|
|
|
|
|
|
|
|
|
messenger.showSnackBar(
|
2026-04-17 14:48:32 +00:00
|
|
|
|
SnackBar(
|
2026-04-16 04:20:53 +00:00
|
|
|
|
content: Column(
|
|
|
|
|
|
mainAxisSize: MainAxisSize.min,
|
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
|
|
|
children: [
|
|
|
|
|
|
Row(
|
|
|
|
|
|
children: [
|
2026-04-17 14:48:32 +00:00
|
|
|
|
Icon(LucideIcons.wifiOff, color: Colors.white, size: 16),
|
|
|
|
|
|
const SizedBox(width: 8),
|
|
|
|
|
|
const Text('オフライン検知', style: TextStyle(fontWeight: FontWeight.bold)),
|
2026-04-16 04:20:53 +00:00
|
|
|
|
],
|
|
|
|
|
|
),
|
2026-04-17 14:48:32 +00:00
|
|
|
|
const SizedBox(height: 4),
|
|
|
|
|
|
const Text('写真を「解析待ち」として保存しました。'),
|
|
|
|
|
|
const Text('オンライン復帰後、ホーム画面から解析できます。'),
|
2026-04-16 04:20:53 +00:00
|
|
|
|
],
|
|
|
|
|
|
),
|
2026-04-17 14:48:32 +00:00
|
|
|
|
duration: const Duration(seconds: 5),
|
|
|
|
|
|
backgroundColor: appColors.warning,
|
2026-04-16 04:20:53 +00:00
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
navigator.pop();
|
|
|
|
|
|
return;
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
debugPrint('Draft save error: $e');
|
|
|
|
|
|
if (!mounted) return;
|
|
|
|
|
|
messenger.showSnackBar(
|
2026-04-17 14:48:32 +00:00
|
|
|
|
const SnackBar(content: Text('写真の一時保存に失敗しました。再度お試しください。')),
|
2026-04-16 04:20:53 +00:00
|
|
|
|
);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-16 08:58:00 +00:00
|
|
|
|
// クォータ事前チェック(日次上限 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: [
|
2026-04-17 14:48:32 +00:00
|
|
|
|
Row(
|
2026-04-16 08:58:00 +00:00
|
|
|
|
children: [
|
2026-04-17 14:48:32 +00:00
|
|
|
|
const Icon(LucideIcons.zap, color: Colors.white, size: 16),
|
|
|
|
|
|
const SizedBox(width: 8),
|
|
|
|
|
|
const Text('本日のAI解析上限(20回)に達しました',
|
2026-04-16 08:58:00 +00:00
|
|
|
|
style: TextStyle(fontWeight: FontWeight.bold)),
|
|
|
|
|
|
],
|
|
|
|
|
|
),
|
|
|
|
|
|
const SizedBox(height: 4),
|
|
|
|
|
|
const Text('写真を「解析待ち」として保存しました。'),
|
|
|
|
|
|
Text('$resetStr 以降にホーム画面から解析できます。'),
|
|
|
|
|
|
],
|
|
|
|
|
|
),
|
|
|
|
|
|
duration: const Duration(seconds: 6),
|
2026-04-17 14:48:32 +00:00
|
|
|
|
backgroundColor: appColors.warning,
|
2026-04-16 08:58:00 +00:00
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
navigator.pop(); // カメラ画面を閉じてホームへ
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
debugPrint('Draft save error (quota): $e');
|
|
|
|
|
|
if (!mounted) return;
|
|
|
|
|
|
messenger.showSnackBar(
|
2026-04-17 14:48:32 +00:00
|
|
|
|
const SnackBar(content: Text('写真の一時保存に失敗しました。再度お試しください。')),
|
2026-04-16 08:58:00 +00:00
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-16 04:20:53 +00:00
|
|
|
|
// オンライン時: 通常の解析フロー
|
|
|
|
|
|
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);
|
|
|
|
|
|
|
2026-04-16 08:58:00 +00:00
|
|
|
|
// API 使用回数をカウントアップ(キャッシュヒット時は実際の API 呼び出しなし)
|
|
|
|
|
|
if (!result.isFromCache) {
|
|
|
|
|
|
await ApiUsageService.increment();
|
|
|
|
|
|
ref.invalidate(apiUsageCountProvider);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-16 04:20:53 +00:00
|
|
|
|
// さけのわ自動マッチング(非同期・バックグラウンド)
|
|
|
|
|
|
// エラーが発生しても登録フローを中断しない
|
|
|
|
|
|
_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));
|
2026-04-17 14:48:32 +00:00
|
|
|
|
messageWidgets.add(Text(
|
2026-04-16 04:20:53 +00:00
|
|
|
|
'※ 解析済みの結果を使用(経験値なし)',
|
2026-04-17 14:48:32 +00:00
|
|
|
|
style: TextStyle(fontSize: 12, color: appColors.textTertiary),
|
2026-04-16 04:20:53 +00:00
|
|
|
|
));
|
|
|
|
|
|
} else {
|
|
|
|
|
|
messageWidgets.add(const SizedBox(height: 4));
|
|
|
|
|
|
messageWidgets.add(Row(
|
|
|
|
|
|
children: [
|
2026-04-17 14:48:32 +00:00
|
|
|
|
Icon(LucideIcons.sparkles, color: appColors.brandAccent, size: 16),
|
2026-04-16 04:20:53 +00:00
|
|
|
|
const SizedBox(width: 8),
|
|
|
|
|
|
Text(
|
|
|
|
|
|
'経験値 +$expGained GET!${isLevelUp ? " (Level UP!)" : ""}',
|
|
|
|
|
|
style: TextStyle(
|
|
|
|
|
|
fontWeight: FontWeight.bold,
|
2026-04-17 14:48:32 +00:00
|
|
|
|
color: appColors.brandAccent,
|
2026-04-16 04:20:53 +00:00
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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,
|
2026-04-17 14:48:32 +00:00
|
|
|
|
color: appColors.success,
|
2026-04-16 04:20:53 +00:00
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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 {
|
2026-04-16 08:58:00 +00:00
|
|
|
|
await DraftService.saveDraft(capturedImages, reason: DraftReason.congestion);
|
2026-04-16 04:20:53 +00:00
|
|
|
|
if (!mounted) return;
|
|
|
|
|
|
navigator.pop(); // Close camera screen
|
|
|
|
|
|
messenger.showSnackBar(
|
2026-04-17 14:48:32 +00:00
|
|
|
|
SnackBar(
|
2026-04-16 04:20:53 +00:00
|
|
|
|
content: Column(
|
|
|
|
|
|
mainAxisSize: MainAxisSize.min,
|
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
|
|
|
children: [
|
2026-04-17 14:48:32 +00:00
|
|
|
|
const Row(
|
2026-04-16 04:20:53 +00:00
|
|
|
|
children: [
|
|
|
|
|
|
Icon(LucideIcons.cloudOff, color: Colors.white, size: 16),
|
|
|
|
|
|
SizedBox(width: 8),
|
|
|
|
|
|
Text('AIサーバー混雑', style: TextStyle(fontWeight: FontWeight.bold)),
|
|
|
|
|
|
],
|
|
|
|
|
|
),
|
2026-04-17 14:48:32 +00:00
|
|
|
|
const SizedBox(height: 4),
|
|
|
|
|
|
const Text('写真を「解析待ち」として保存しました。'),
|
|
|
|
|
|
const Text('時間をおいてホーム画面から解析できます。'),
|
2026-04-16 04:20:53 +00:00
|
|
|
|
],
|
|
|
|
|
|
),
|
2026-04-17 14:48:32 +00:00
|
|
|
|
duration: const Duration(seconds: 5),
|
|
|
|
|
|
backgroundColor: appColors.warning,
|
2026-04-16 04:20:53 +00:00
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
} catch (draftError) {
|
|
|
|
|
|
debugPrint('Draft save also failed: $draftError');
|
|
|
|
|
|
if (!mounted) return;
|
|
|
|
|
|
messenger.showSnackBar(
|
|
|
|
|
|
const SnackBar(content: Text('解析もドラフト保存も失敗しました。再試行してください。')),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-16 08:58:00 +00:00
|
|
|
|
// Quota エラー(429)→ ドラフト保存してカメラを閉じる
|
2026-04-16 04:20:53 +00:00
|
|
|
|
final errStr = e.toString();
|
|
|
|
|
|
if (errStr.contains('Quota') || errStr.contains('429')) {
|
2026-04-16 08:58:00 +00:00
|
|
|
|
try {
|
|
|
|
|
|
await DraftService.saveDraft(capturedImages, reason: DraftReason.quotaLimit);
|
2026-04-18 05:18:27 +00:00
|
|
|
|
ref.read(quotaLockoutProvider.notifier).set(DateTime.now().add(const Duration(minutes: 1)));
|
2026-04-16 08:58:00 +00:00
|
|
|
|
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: [
|
2026-04-17 14:48:32 +00:00
|
|
|
|
Icon(LucideIcons.zap, color: Colors.white, size: 16),
|
2026-04-16 08:58:00 +00:00
|
|
|
|
SizedBox(width: 8),
|
|
|
|
|
|
Text('本日のAI解析上限(20回)に達しました',
|
|
|
|
|
|
style: TextStyle(fontWeight: FontWeight.bold)),
|
|
|
|
|
|
],
|
|
|
|
|
|
),
|
|
|
|
|
|
const SizedBox(height: 4),
|
|
|
|
|
|
const Text('写真を「解析待ち」として保存しました。'),
|
|
|
|
|
|
Text('$resetStr 以降にホーム画面から解析できます。'),
|
|
|
|
|
|
],
|
|
|
|
|
|
),
|
|
|
|
|
|
duration: const Duration(seconds: 6),
|
2026-04-17 14:48:32 +00:00
|
|
|
|
backgroundColor: appColors.warning,
|
2026-04-16 08:58:00 +00:00
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
} catch (draftError) {
|
|
|
|
|
|
debugPrint('Draft save failed after 429: $draftError');
|
|
|
|
|
|
if (!mounted) return;
|
|
|
|
|
|
messenger.showSnackBar(
|
|
|
|
|
|
const SnackBar(content: Text('解析もドラフト保存も失敗しました。再試行してください。')),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
return;
|
2026-04-16 04:20:53 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-17 14:48:32 +00:00
|
|
|
|
debugPrint('Analysis error: $e');
|
2026-04-16 04:20:53 +00:00
|
|
|
|
messenger.showSnackBar(
|
|
|
|
|
|
SnackBar(
|
2026-04-17 14:48:32 +00:00
|
|
|
|
content: const Text('解析に失敗しました。時間をおいて再試行してください。'),
|
2026-04-16 04:20:53 +00:00
|
|
|
|
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');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|