From ac2a54d07a0391a14a9e13e255bfd1046500c04c Mon Sep 17 00:00:00 2001 From: Ponshu Developer Date: Thu, 16 Apr 2026 17:58:00 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20AI=E4=BD=BF=E7=94=A8=E5=9B=9E=E6=95=B0?= =?UTF-8?q?=E3=83=88=E3=83=A9=E3=83=83=E3=82=AD=E3=83=B3=E3=82=B0=20+=20?= =?UTF-8?q?=E3=82=AF=E3=82=A9=E3=83=BC=E3=82=BF=E4=B8=8A=E9=99=90=E6=99=82?= =?UTF-8?q?=E3=83=89=E3=83=A9=E3=83=95=E3=83=88=E4=BF=9D=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- lib/screens/camera_analysis_mixin.dart | 96 ++++++++++++++++++-- lib/screens/pending_analysis_screen.dart | 40 +++++++- lib/services/api_usage_service.dart | 75 +++++++++++++++ lib/services/draft_service.dart | 21 ++++- lib/widgets/gamification/activity_stats.dart | 64 +++++++++++++ pubspec.yaml | 2 +- 6 files changed, 286 insertions(+), 12 deletions(-) create mode 100644 lib/services/api_usage_service.dart diff --git a/lib/screens/camera_analysis_mixin.dart b/lib/screens/camera_analysis_mixin.dart index bf74232..6d9eed1 100644 --- a/lib/screens/camera_analysis_mixin.dart +++ b/lib/screens/camera_analysis_mixin.dart @@ -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 on ConsumerState 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 on ConsumerState } } + // クォータ事前チェック(日次上限 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 on ConsumerState final box = Hive.box('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 on ConsumerState // 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 on ConsumerState 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()!; diff --git a/lib/screens/pending_analysis_screen.dart b/lib/screens/pending_analysis_screen.dart index 3036f41..5b5b6e8 100644 --- a/lib/screens/pending_analysis_screen.dart +++ b/lib/screens/pending_analysis_screen.dart @@ -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 { } } + /// ドラフト理由の説明文を返す + 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 _analyzeAllDrafts() async { final appColors = Theme.of(context).extension()!; @@ -353,9 +372,24 @@ class _PendingAnalysisScreenState extends ConsumerState { '解析待ち', 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), diff --git a/lib/services/api_usage_service.dart b/lib/services/api_usage_service.dart new file mode 100644 index 0000000..cfd7ebc --- /dev/null +++ b/lib/services/api_usage_service.dart @@ -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 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 increment() async { + final prefs = await SharedPreferences.getInstance(); + final current = await getCount(); + await prefs.setInt(_keyCount, current + 1); + } + + /// 残り回数(0 以上) + static Future getRemaining() async { + final count = await getCount(); + return (dailyLimit - count).clamp(0, dailyLimit); + } + + /// 無料枠を使い切っているか + static Future isExhausted() async { + return await getCount() >= dailyLimit; + } +} + +/// ActivityStats / カメラ画面で使う Riverpod プロバイダ。 +/// increment() 後に ref.invalidate(apiUsageCountProvider) で UI を更新する。 +final apiUsageCountProvider = FutureProvider.autoDispose((ref) async { + return ApiUsageService.getCount(); +}); diff --git a/lib/services/draft_service.dart b/lib/services/draft_service.dart index 9a109d6..43c5446 100644 --- a/lib/services/draft_service.dart +++ b/lib/services/draft_service.dart @@ -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 saveDraft(List photoPaths) async { + static Future saveDraft( + List photoPaths, { + DraftReason reason = DraftReason.offline, + }) async { try { final box = Hive.box('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: [], ), diff --git a/lib/widgets/gamification/activity_stats.dart b/lib/widgets/gamification/activity_stats.dart index a6a2c99..e66fef5 100644 --- a/lib/widgets/gamification/activity_stats.dart +++ b/lib/widgets/gamification/activity_stats.dart @@ -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()!; + 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(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), + ), + ], + ], + ), + ), ], ); }, diff --git a/pubspec.yaml b/pubspec.yaml index 5d10cd6..18ee739 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.40+47 +version: 1.0.41+48 environment: sdk: ^3.10.1