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:
parent
d39db78c80
commit
ac2a54d07a
|
|
@ -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>()!;
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
@ -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: [],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue