feat: AI使用回数トラッキング + クォータ上限時ドラフト保存

- ApiUsageService: SharedPreferences で Gemini 日次使用回数を追跡
  - UTC 08:00(=日本時間 17:00)でリセット
  - 上限 20回/日(プロジェクトあたりの無料枠)
- DraftReason enum: offline / quotaLimit / congestion を区別
- camera_analysis_mixin: 解析前にクォータを事前チェック
  - 上限到達時は Draft 保存してカメラを閉じる(写真は失われない)
  - 429 エラー時も同様に Draft 保存(従来はエラー表示のみで写真消失)
  - API 呼び出し成功時(キャッシュ除く)にカウントアップ
- pending_analysis_screen: ドラフト理由を各アイテムに表示
  - クォータ: リセット時刻つきの警告(オレンジ)
  - 混雑 / オフライン: 理由別メッセージ
- ActivityStats: AI 使用状況 bento カードを追加
  - 今日のAI解析 X / 20回 + プログレスバー
  - 残り5回以下でオレンジ、上限到達で赤

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Ponshu Developer 2026-04-16 17:58:00 +09:00
parent d39db78c80
commit ac2a54d07a
6 changed files with 286 additions and 12 deletions

View File

@ -8,6 +8,7 @@ import '../models/sake_item.dart';
import '../providers/gemini_provider.dart';
import '../providers/sakenowa_providers.dart';
import '../providers/theme_provider.dart';
import '../services/api_usage_service.dart';
import '../services/draft_service.dart';
import '../services/gamification_service.dart';
import '../services/gemini_exceptions.dart';
@ -38,7 +39,7 @@ mixin CameraAnalysisMixin<T extends ConsumerStatefulWidget> on ConsumerState<T>
debugPrint('Offline detected: Saving as draft...');
try {
await DraftService.saveDraft(capturedImages);
await DraftService.saveDraft(capturedImages, reason: DraftReason.offline);
if (!mounted) return;
@ -77,6 +78,49 @@ mixin CameraAnalysisMixin<T extends ConsumerStatefulWidget> on ConsumerState<T>
}
}
// 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: [
const Row(
children: [
Icon(LucideIcons.zap, color: Colors.orange, size: 16),
SizedBox(width: 8),
Text('本日のAI解析上限20回に達しました',
style: TextStyle(fontWeight: FontWeight.bold)),
],
),
const SizedBox(height: 4),
const Text('写真を「解析待ち」として保存しました。'),
Text('$resetStr 以降にホーム画面から解析できます。'),
],
),
duration: const Duration(seconds: 6),
backgroundColor: Colors.orange,
),
);
navigator.pop(); //
} catch (e) {
debugPrint('Draft save error (quota): $e');
if (!mounted) return;
messenger.showSnackBar(
SnackBar(content: Text('保存エラー: $e')),
);
}
return;
}
// :
if (!mounted) return;
// ignore: use_build_context_synchronously
@ -125,6 +169,12 @@ mixin CameraAnalysisMixin<T extends ConsumerStatefulWidget> on ConsumerState<T>
final box = Hive.box<SakeItem>('sake_items');
await box.add(sakeItem);
// API 使 API
if (!result.isFromCache) {
await ApiUsageService.increment();
ref.invalidate(apiUsageCountProvider);
}
//
//
_performSakenowaMatching(sakeItem).catchError((error) {
@ -243,7 +293,7 @@ mixin CameraAnalysisMixin<T extends ConsumerStatefulWidget> on ConsumerState<T>
// AIサーバー混雑503
if (e is GeminiCongestionException) {
try {
await DraftService.saveDraft(capturedImages);
await DraftService.saveDraft(capturedImages, reason: DraftReason.congestion);
if (!mounted) return;
navigator.pop(); // Close camera screen
messenger.showSnackBar(
@ -278,12 +328,46 @@ mixin CameraAnalysisMixin<T extends ConsumerStatefulWidget> on ConsumerState<T>
return;
}
// Quota 429
// Quota 429
final errStr = e.toString();
if (errStr.contains('Quota') || errStr.contains('429')) {
setState(() {
quotaLockoutTime = DateTime.now().add(const Duration(minutes: 1));
});
try {
await DraftService.saveDraft(capturedImages, reason: DraftReason.quotaLimit);
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: [
Icon(LucideIcons.zap, color: Colors.orange, size: 16),
SizedBox(width: 8),
Text('本日のAI解析上限20回に達しました',
style: TextStyle(fontWeight: FontWeight.bold)),
],
),
const SizedBox(height: 4),
const Text('写真を「解析待ち」として保存しました。'),
Text('$resetStr 以降にホーム画面から解析できます。'),
],
),
duration: const Duration(seconds: 6),
backgroundColor: Colors.orange,
),
);
} catch (draftError) {
debugPrint('Draft save failed after 429: $draftError');
if (!mounted) return;
messenger.showSnackBar(
const SnackBar(content: Text('解析もドラフト保存も失敗しました。再試行してください。')),
);
}
return;
}
final appColors = Theme.of(context).extension<AppColors>()!;

View File

@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lucide_icons/lucide_icons.dart';
import '../models/sake_item.dart';
import '../providers/gemini_provider.dart';
import '../services/api_usage_service.dart';
import '../services/draft_service.dart';
import '../services/network_service.dart';
import '../theme/app_colors.dart';
@ -54,6 +55,24 @@ class _PendingAnalysisScreenState extends ConsumerState<PendingAnalysisScreen> {
}
}
///
String _getDraftNote(SakeItem draft) {
final desc = draft.hiddenSpecs.description ?? '';
switch (desc) {
case 'quota':
final resetTime = ApiUsageService.getNextResetTime();
final resetStr = '${resetTime.hour}:${resetTime.minute.toString().padLeft(2, '0')}';
return 'AI上限20回/日)に達したため保留中。次のリセット $resetStr 以降に解析可';
case 'congestion':
return 'AIサーバー混雑のため保留中。「すべて解析」で再試行できます';
default:
return 'オフライン時に保存。オンライン接続後に解析できます';
}
}
bool _isQuotaDraft(SakeItem draft) =>
draft.hiddenSpecs.description == 'quota';
Future<void> _analyzeAllDrafts() async {
final appColors = Theme.of(context).extension<AppColors>()!;
@ -353,9 +372,24 @@ class _PendingAnalysisScreenState extends ConsumerState<PendingAnalysisScreen> {
'解析待ち',
style: TextStyle(fontWeight: FontWeight.bold),
),
subtitle: Text(
'撮影日時: ${draft.metadata.createdAt.toString().substring(0, 16)}',
style: TextStyle(fontSize: 12, color: appColors.textSecondary),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'撮影日時: ${draft.metadata.createdAt.toString().substring(0, 16)}',
style: TextStyle(fontSize: 12, color: appColors.textSecondary),
),
const SizedBox(height: 2),
Text(
_getDraftNote(draft),
style: TextStyle(
fontSize: 11,
color: _isQuotaDraft(draft)
? Colors.orange
: appColors.textTertiary,
),
),
],
),
trailing: IconButton(
icon: Icon(LucideIcons.trash2, color: appColors.error),

View File

@ -0,0 +1,75 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// Gemini API 使
///
/// - : 1 20/
/// - : UTC 08:00= 17:00 JST / 16:00 JST
/// Gemini API Pacific Time UTC+9
/// (PST=UTC-8) 17:00 JST(PDT=UTC-7) 16:00 JST
class ApiUsageService {
static const int dailyLimit = 20;
static const _keyCount = 'gemini_usage_count';
static const _keyWindowStart = 'gemini_window_start';
/// UTC 08:00
static DateTime getCurrentWindowStart() {
final now = DateTime.now().toUtc();
final todayReset = DateTime.utc(now.year, now.month, now.day, 8, 0, 0);
return now.isBefore(todayReset)
? todayReset.subtract(const Duration(days: 1))
: todayReset;
}
///
static DateTime getNextResetTime() {
return getCurrentWindowStart().add(const Duration(days: 1)).toLocal();
}
/// 使
static Future<int> getCount() async {
final prefs = await SharedPreferences.getInstance();
final windowStart = getCurrentWindowStart();
final storedStr = prefs.getString(_keyWindowStart);
if (storedStr != null) {
final storedWindow = DateTime.parse(storedStr);
if (storedWindow.isBefore(windowStart)) {
//
await prefs.setInt(_keyCount, 0);
await prefs.setString(_keyWindowStart, windowStart.toIso8601String());
return 0;
}
} else {
//
await prefs.setString(_keyWindowStart, windowStart.toIso8601String());
}
return prefs.getInt(_keyCount) ?? 0;
}
/// 使 1
static Future<void> increment() async {
final prefs = await SharedPreferences.getInstance();
final current = await getCount();
await prefs.setInt(_keyCount, current + 1);
}
/// 0
static Future<int> getRemaining() async {
final count = await getCount();
return (dailyLimit - count).clamp(0, dailyLimit);
}
/// 使
static Future<bool> isExhausted() async {
return await getCount() >= dailyLimit;
}
}
/// ActivityStats / 使 Riverpod
/// increment() ref.invalidate(apiUsageCountProvider) UI
final apiUsageCountProvider = FutureProvider.autoDispose<int>((ref) async {
return ApiUsageService.getCount();
});

View File

@ -4,6 +4,13 @@ import 'package:uuid/uuid.dart';
import '../models/sake_item.dart';
import 'gemini_service.dart';
/// Draft
enum DraftReason {
offline, //
quotaLimit, // AI 使20/
congestion, // AI 503
}
/// Draft
///
///
@ -29,13 +36,23 @@ class DraftService {
/// ```dart
/// final draftKey = await DraftService.saveDraft([imagePath1, imagePath2]);
/// ```
static Future<String> saveDraft(List<String> photoPaths) async {
static Future<String> saveDraft(
List<String> photoPaths, {
DraftReason reason = DraftReason.offline,
}) async {
try {
final box = Hive.box<SakeItem>('sake_items');
// FIX: draftPhotoPathにimagePathsに保存
final firstPhotoPath = photoPaths.isNotEmpty ? photoPaths.first : '';
// reason description
final reasonKey = switch (reason) {
DraftReason.offline => 'offline',
DraftReason.quotaLimit => 'quota',
DraftReason.congestion => 'congestion',
};
// Draft用の仮データを作成
final draftItem = SakeItem(
id: _uuid.v4(),
@ -49,7 +66,7 @@ class DraftService {
rating: null,
),
hiddenSpecs: HiddenSpecs(
description: 'オフライン時に撮影された写真です。オンライン復帰後に自動解析されます。',
description: reasonKey,
tasteStats: {},
flavorTags: [],
),

View File

@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lucide_icons/lucide_icons.dart';
import '../../providers/sake_list_provider.dart';
import '../../models/schema/item_type.dart';
import '../../services/api_usage_service.dart';
import '../../theme/app_colors.dart';
class ActivityStats extends ConsumerWidget {
@ -12,6 +13,8 @@ class ActivityStats extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final allSakeAsync = ref.watch(allSakeItemsProvider);
final apiUsageAsync = ref.watch(apiUsageCountProvider);
return allSakeAsync.when(
data: (sakes) {
final individualSakes = sakes.where((s) => s.itemType == ItemType.sake).toList();
@ -25,7 +28,17 @@ class ActivityStats extends ConsumerWidget {
}).toSet();
final recordingDays = dates.length;
final apiCount = apiUsageAsync.asData?.value ?? 0;
final remaining = (ApiUsageService.dailyLimit - apiCount).clamp(0, ApiUsageService.dailyLimit);
final isExhausted = remaining == 0;
final isLow = remaining <= 5 && !isExhausted;
final appColors = Theme.of(context).extension<AppColors>()!;
final apiColor = isExhausted
? appColors.error
: isLow
? Colors.orange
: appColors.brandPrimary;
// Bento Grid: 2
return Column(
@ -134,6 +147,57 @@ class ActivityStats extends ConsumerWidget {
),
],
),
const SizedBox(height: 8),
// AI 使
_BentoCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(LucideIcons.sparkles, size: 14, color: apiColor),
const SizedBox(width: 6),
Text(
'今日のAI解析',
style: TextStyle(fontSize: 11, color: appColors.textSecondary),
),
const Spacer(),
Text(
'$apiCount / ${ApiUsageService.dailyLimit}',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: apiColor,
),
),
],
),
const SizedBox(height: 8),
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value: apiCount / ApiUsageService.dailyLimit,
backgroundColor: appColors.surfaceSubtle,
valueColor: AlwaysStoppedAnimation<Color>(apiColor),
minHeight: 5,
),
),
if (isExhausted) ...[
const SizedBox(height: 6),
Text(
'本日の上限に達しました。写真は保存されています。',
style: TextStyle(fontSize: 10, color: appColors.error),
),
] else if (isLow) ...[
const SizedBox(height: 6),
Text(
'残り$remaining回です',
style: const TextStyle(fontSize: 10, color: Colors.orange),
),
],
],
),
),
],
);
},

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.40+47
version: 1.0.41+48
environment:
sdk: ^3.10.1