diff --git a/lib/screens/camera_screen.dart b/lib/screens/camera_screen.dart index 7429fe6..b6470d0 100644 --- a/lib/screens/camera_screen.dart +++ b/lib/screens/camera_screen.dart @@ -491,54 +491,71 @@ class _CameraScreenState extends ConsumerState with SingleTickerPr await settingsBox.put('sake_sort_order', currentOrder); // --- v1.3 Gamification Hook --- - // Award EXP - final userProfileState = ref.read(userProfileProvider); - final prevLevel = userProfileState.level; + // キャッシュヒット結果にはEXP付与しない(同一画像の重複スキャン対策) + int expGained = 0; + int newLevel = 0; + bool isLevelUp = false; + List newBadges = []; - await ref.read(userProfileProvider.notifier).updateTotalExp(userProfileState.totalExp + 10); + if (!result.isFromCache) { + final userProfileState = ref.read(userProfileProvider); + final prevLevel = userProfileState.level; + expGained = 10; - // Check and unlock badges - final newBadges = await GamificationService.checkAndUnlockBadges(ref); + await ref.read(userProfileProvider.notifier).updateTotalExp(userProfileState.totalExp + expGained); + newBadges = await GamificationService.checkAndUnlockBadges(ref); - // Refetch updated state for level comparison - final updatedProfile = ref.read(userProfileProvider); - final newLevel = updatedProfile.level; - final isLevelUp = newLevel > prevLevel; + 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)'); + } - // Debug: Verify save debugPrint('Saved to Hive: ${sakeItem.displayData.displayName} (ID: ${sakeItem.id})'); debugPrint('Total items in box: ${box.length}'); - debugPrint('Prepended to sort order (now ${currentOrder.length} items)'); - debugPrint('Gamification: EXP +10 (Total: ${updatedProfile.totalExp}) Level: $prevLevel -> $newLevel'); + + // 解析完了後に画像リストをクリア(次の撮影セッション用) + setState(() => _capturedImages.clear()); if (!mounted) return; navigator.pop(); // Close AnalyzingDialog navigator.pop(); // Close Camera Screen (Return to Home) - // Success Message (with EXP/Level Up/Badge info) + // Success Message final isDark = Theme.of(context).brightness == Brightness.dark; final List messageWidgets = [ Text('${sakeItem.displayData.displayName} を登録しました!'), - const SizedBox(height: 4), - Row( + ]; + + 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( - '経験値 +10 GET! ${isLevelUp ? " (Level UP!)" : ""}', - style: TextStyle( - fontWeight: FontWeight.bold, - color: isDark ? Colors.yellow.shade200 : Colors.yellowAccent, - ), + '経験値 +$expGained GET!${isLevelUp ? " (Level UP!)" : ""}', + style: TextStyle( + fontWeight: FontWeight.bold, + color: isDark ? Colors.yellow.shade200 : Colors.yellowAccent, + ), ), ], - ), - ]; + )); + } - // Add badge notifications if (newBadges.isNotEmpty) { messageWidgets.add(const SizedBox(height: 8)); for (var badge in newBadges) { @@ -603,7 +620,8 @@ class _CameraScreenState extends ConsumerState with SingleTickerPr backgroundColor: Colors.orange, ), ); - } catch (_) { + } catch (e) { + debugPrint('Draft save also failed: $e'); if (!mounted) return; messenger.showSnackBar( const SnackBar(content: Text('解析もドラフト保存も失敗しました。再試行してください。')), diff --git a/lib/services/gemini_service.dart b/lib/services/gemini_service.dart index 70020b1..df5a5e4 100644 --- a/lib/services/gemini_service.dart +++ b/lib/services/gemini_service.dart @@ -96,7 +96,7 @@ class GeminiService { final cached = await AnalysisCacheService.getCached(imageHash); if (cached != null) { debugPrint('Proxy cache hit: skipping API call'); - return cached; + return cached.asCached(); } } @@ -162,20 +162,7 @@ class GeminiService { final result = SakeAnalysisResult.fromJson(data); - // スキーマ準拠チェック - if (result.tasteStats.isEmpty || - result.tasteStats.values.every((v) => v == 0)) { - debugPrint('WARNING: AI returned empty or zero taste stats. This item will not form a valid chart.'); - } else { - // Simple check - final requiredKeys = ['aroma', 'sweetness', 'acidity', 'bitterness', 'body']; - final actualKeys = result.tasteStats.keys.toList(); - final missing = requiredKeys.where((k) => !actualKeys.contains(k)).toList(); - - if (missing.isNotEmpty) { - debugPrint('WARNING: AI response missing keys: $missing. Old schema?'); - } - } + // tasteStats の補完・バリデーションは SakeAnalysisResult.fromJson 内で実施済み // キャッシュに保存(次回同一画像はAPI不使用) if (imagePaths.isNotEmpty) { @@ -221,7 +208,7 @@ class GeminiService { final cached = await AnalysisCacheService.getCached(imageHash); if (cached != null) { debugPrint('Cache hit: skipping API call'); - return cached; + return cached.asCached(); } } @@ -368,7 +355,7 @@ class SakeAnalysisResult { final int? confidenceScore; final List flavorTags; final Map tasteStats; - + // New Fields final double? alcoholContent; final int? polishingRatio; @@ -377,6 +364,10 @@ class SakeAnalysisResult { final String? yeast; final String? manufacturingYearMonth; + /// キャッシュから返された結果かどうか(EXP付与・新規登録の判定に使用) + /// JSON には含まない(キャッシュ保存・復元時は常に false) + final bool isFromCache; + SakeAnalysisResult({ this.name, this.brand, @@ -393,14 +384,33 @@ class SakeAnalysisResult { this.riceVariety, this.yeast, this.manufacturingYearMonth, + this.isFromCache = false, }); + /// キャッシュヒット用コピー + SakeAnalysisResult asCached() => SakeAnalysisResult( + name: name, brand: brand, prefecture: prefecture, type: type, + description: description, catchCopy: catchCopy, confidenceScore: confidenceScore, + flavorTags: flavorTags, tasteStats: tasteStats, alcoholContent: alcoholContent, + polishingRatio: polishingRatio, sakeMeterValue: sakeMeterValue, + riceVariety: riceVariety, yeast: yeast, + manufacturingYearMonth: manufacturingYearMonth, + isFromCache: true, + ); + factory SakeAnalysisResult.fromJson(Map json) { - // Helper to extract int from map safely + // tasteStats: 欠損キーを 3 で補完、範囲外(1〜5)をクランプ + const requiredStatKeys = ['aroma', 'sweetness', 'acidity', 'bitterness', 'body']; Map stats = {}; if (json['tasteStats'] is Map) { final map = json['tasteStats'] as Map; - stats = map.map((key, value) => MapEntry(key.toString(), (value as num?)?.toInt() ?? 3)); + stats = map.map((key, value) => MapEntry( + key.toString(), + ((value as num?)?.toInt() ?? 3).clamp(1, 5), + )); + } + for (final key in requiredStatKeys) { + stats.putIfAbsent(key, () => 3); } return SakeAnalysisResult( diff --git a/pubspec.yaml b/pubspec.yaml index 182ae08..45eaba0 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 1.0.37+44 +version: 1.0.38+45 environment: sdk: ^3.10.1