fix: prevent EXP farming on cache hits + fix tasteStats validation

- SakeAnalysisResult.isFromCache flag added (not serialized to JSON)
- Both cache-hit paths return result.asCached() to signal caller
- camera_screen: EXP +10 only awarded on fresh API calls, not cache hits
- camera_screen: show '解析済みの結果を使用(経験値なし)' on cache hit
- camera_screen: clear _capturedImages after successful analysis
- camera_screen: catch(_) -> catch(e) with debugPrint logging
- SakeAnalysisResult.fromJson: auto-fill missing tasteStats keys with 3,
  clamp all values to 1-5 range to prevent broken charts
- Bump version 1.0.37+44 -> 1.0.38+45

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Ponshu Developer 2026-04-16 10:46:42 +09:00
parent ee7e3b2646
commit e82f66e44e
3 changed files with 73 additions and 45 deletions

View File

@ -491,54 +491,71 @@ class _CameraScreenState extends ConsumerState<CameraScreen> with SingleTickerPr
await settingsBox.put('sake_sort_order', currentOrder);
// --- v1.3 Gamification Hook ---
// Award EXP
// 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 + 10);
await ref.read(userProfileProvider.notifier).updateTotalExp(userProfileState.totalExp + expGained);
newBadges = await GamificationService.checkAndUnlockBadges(ref);
// Check and unlock badges
final newBadges = await GamificationService.checkAndUnlockBadges(ref);
// Refetch updated state for level comparison
final updatedProfile = ref.read(userProfileProvider);
final newLevel = updatedProfile.level;
final isLevelUp = newLevel > prevLevel;
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<Widget> 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!)" : ""}',
'経験値 +$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<CameraScreen> with SingleTickerPr
backgroundColor: Colors.orange,
),
);
} catch (_) {
} catch (e) {
debugPrint('Draft save also failed: $e');
if (!mounted) return;
messenger.showSnackBar(
const SnackBar(content: Text('解析もドラフト保存も失敗しました。再試行してください。')),

View File

@ -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();
}
}
@ -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<String, dynamic> json) {
// Helper to extract int from map safely
// tasteStats: 3 (15)
const requiredStatKeys = ['aroma', 'sweetness', 'acidity', 'bitterness', 'body'];
Map<String, int> 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(

View File

@ -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