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/gemini_provider.dart';
|
||||||
import '../providers/sakenowa_providers.dart';
|
import '../providers/sakenowa_providers.dart';
|
||||||
import '../providers/theme_provider.dart';
|
import '../providers/theme_provider.dart';
|
||||||
|
import '../services/api_usage_service.dart';
|
||||||
import '../services/draft_service.dart';
|
import '../services/draft_service.dart';
|
||||||
import '../services/gamification_service.dart';
|
import '../services/gamification_service.dart';
|
||||||
import '../services/gemini_exceptions.dart';
|
import '../services/gemini_exceptions.dart';
|
||||||
|
|
@ -38,7 +39,7 @@ mixin CameraAnalysisMixin<T extends ConsumerStatefulWidget> on ConsumerState<T>
|
||||||
debugPrint('Offline detected: Saving as draft...');
|
debugPrint('Offline detected: Saving as draft...');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await DraftService.saveDraft(capturedImages);
|
await DraftService.saveDraft(capturedImages, reason: DraftReason.offline);
|
||||||
|
|
||||||
if (!mounted) return;
|
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;
|
if (!mounted) return;
|
||||||
// ignore: use_build_context_synchronously
|
// ignore: use_build_context_synchronously
|
||||||
|
|
@ -125,6 +169,12 @@ mixin CameraAnalysisMixin<T extends ConsumerStatefulWidget> on ConsumerState<T>
|
||||||
final box = Hive.box<SakeItem>('sake_items');
|
final box = Hive.box<SakeItem>('sake_items');
|
||||||
await box.add(sakeItem);
|
await box.add(sakeItem);
|
||||||
|
|
||||||
|
// API 使用回数をカウントアップ(キャッシュヒット時は実際の API 呼び出しなし)
|
||||||
|
if (!result.isFromCache) {
|
||||||
|
await ApiUsageService.increment();
|
||||||
|
ref.invalidate(apiUsageCountProvider);
|
||||||
|
}
|
||||||
|
|
||||||
// さけのわ自動マッチング(非同期・バックグラウンド)
|
// さけのわ自動マッチング(非同期・バックグラウンド)
|
||||||
// エラーが発生しても登録フローを中断しない
|
// エラーが発生しても登録フローを中断しない
|
||||||
_performSakenowaMatching(sakeItem).catchError((error) {
|
_performSakenowaMatching(sakeItem).catchError((error) {
|
||||||
|
|
@ -243,7 +293,7 @@ mixin CameraAnalysisMixin<T extends ConsumerStatefulWidget> on ConsumerState<T>
|
||||||
// AIサーバー混雑(503)→ ドラフト保存してオフライン時と同じ扱いに
|
// AIサーバー混雑(503)→ ドラフト保存してオフライン時と同じ扱いに
|
||||||
if (e is GeminiCongestionException) {
|
if (e is GeminiCongestionException) {
|
||||||
try {
|
try {
|
||||||
await DraftService.saveDraft(capturedImages);
|
await DraftService.saveDraft(capturedImages, reason: DraftReason.congestion);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
navigator.pop(); // Close camera screen
|
navigator.pop(); // Close camera screen
|
||||||
messenger.showSnackBar(
|
messenger.showSnackBar(
|
||||||
|
|
@ -278,12 +328,46 @@ mixin CameraAnalysisMixin<T extends ConsumerStatefulWidget> on ConsumerState<T>
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Quota エラー(429)→ ロックアウト
|
// Quota エラー(429)→ ドラフト保存してカメラを閉じる
|
||||||
final errStr = e.toString();
|
final errStr = e.toString();
|
||||||
if (errStr.contains('Quota') || errStr.contains('429')) {
|
if (errStr.contains('Quota') || errStr.contains('429')) {
|
||||||
setState(() {
|
try {
|
||||||
quotaLockoutTime = DateTime.now().add(const Duration(minutes: 1));
|
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>()!;
|
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 'package:lucide_icons/lucide_icons.dart';
|
||||||
import '../models/sake_item.dart';
|
import '../models/sake_item.dart';
|
||||||
import '../providers/gemini_provider.dart';
|
import '../providers/gemini_provider.dart';
|
||||||
|
import '../services/api_usage_service.dart';
|
||||||
import '../services/draft_service.dart';
|
import '../services/draft_service.dart';
|
||||||
import '../services/network_service.dart';
|
import '../services/network_service.dart';
|
||||||
import '../theme/app_colors.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 {
|
Future<void> _analyzeAllDrafts() async {
|
||||||
final appColors = Theme.of(context).extension<AppColors>()!;
|
final appColors = Theme.of(context).extension<AppColors>()!;
|
||||||
|
|
||||||
|
|
@ -353,9 +372,24 @@ class _PendingAnalysisScreenState extends ConsumerState<PendingAnalysisScreen> {
|
||||||
'解析待ち',
|
'解析待ち',
|
||||||
style: TextStyle(fontWeight: FontWeight.bold),
|
style: TextStyle(fontWeight: FontWeight.bold),
|
||||||
),
|
),
|
||||||
subtitle: Text(
|
subtitle: Column(
|
||||||
'撮影日時: ${draft.metadata.createdAt.toString().substring(0, 16)}',
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
style: TextStyle(fontSize: 12, color: appColors.textSecondary),
|
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(
|
trailing: IconButton(
|
||||||
icon: Icon(LucideIcons.trash2, color: appColors.error),
|
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 '../models/sake_item.dart';
|
||||||
import 'gemini_service.dart';
|
import 'gemini_service.dart';
|
||||||
|
|
||||||
|
/// 解析待ち(Draft)になった理由
|
||||||
|
enum DraftReason {
|
||||||
|
offline, // オフライン時に撮影
|
||||||
|
quotaLimit, // AI 使用回数が上限(20回/日)に達した
|
||||||
|
congestion, // AI サーバー混雑(503)
|
||||||
|
}
|
||||||
|
|
||||||
/// Draft(解析待ちアイテム)管理サービス
|
/// Draft(解析待ちアイテム)管理サービス
|
||||||
///
|
///
|
||||||
/// オフライン時に撮影した写真を一時保存し、
|
/// オフライン時に撮影した写真を一時保存し、
|
||||||
|
|
@ -29,13 +36,23 @@ class DraftService {
|
||||||
/// ```dart
|
/// ```dart
|
||||||
/// final draftKey = await DraftService.saveDraft([imagePath1, imagePath2]);
|
/// 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 {
|
try {
|
||||||
final box = Hive.box<SakeItem>('sake_items');
|
final box = Hive.box<SakeItem>('sake_items');
|
||||||
|
|
||||||
// FIX: 最初の画像をdraftPhotoPathに、全画像をimagePathsに保存
|
// FIX: 最初の画像をdraftPhotoPathに、全画像をimagePathsに保存
|
||||||
final firstPhotoPath = photoPaths.isNotEmpty ? photoPaths.first : '';
|
final firstPhotoPath = photoPaths.isNotEmpty ? photoPaths.first : '';
|
||||||
|
|
||||||
|
// reason を description フィールドに格納(解析時に上書きされる)
|
||||||
|
final reasonKey = switch (reason) {
|
||||||
|
DraftReason.offline => 'offline',
|
||||||
|
DraftReason.quotaLimit => 'quota',
|
||||||
|
DraftReason.congestion => 'congestion',
|
||||||
|
};
|
||||||
|
|
||||||
// Draft用の仮データを作成
|
// Draft用の仮データを作成
|
||||||
final draftItem = SakeItem(
|
final draftItem = SakeItem(
|
||||||
id: _uuid.v4(),
|
id: _uuid.v4(),
|
||||||
|
|
@ -49,7 +66,7 @@ class DraftService {
|
||||||
rating: null,
|
rating: null,
|
||||||
),
|
),
|
||||||
hiddenSpecs: HiddenSpecs(
|
hiddenSpecs: HiddenSpecs(
|
||||||
description: 'オフライン時に撮影された写真です。オンライン復帰後に自動解析されます。',
|
description: reasonKey,
|
||||||
tasteStats: {},
|
tasteStats: {},
|
||||||
flavorTags: [],
|
flavorTags: [],
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:lucide_icons/lucide_icons.dart';
|
import 'package:lucide_icons/lucide_icons.dart';
|
||||||
import '../../providers/sake_list_provider.dart';
|
import '../../providers/sake_list_provider.dart';
|
||||||
import '../../models/schema/item_type.dart';
|
import '../../models/schema/item_type.dart';
|
||||||
|
import '../../services/api_usage_service.dart';
|
||||||
import '../../theme/app_colors.dart';
|
import '../../theme/app_colors.dart';
|
||||||
|
|
||||||
class ActivityStats extends ConsumerWidget {
|
class ActivityStats extends ConsumerWidget {
|
||||||
|
|
@ -12,6 +13,8 @@ class ActivityStats extends ConsumerWidget {
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final allSakeAsync = ref.watch(allSakeItemsProvider);
|
final allSakeAsync = ref.watch(allSakeItemsProvider);
|
||||||
|
|
||||||
|
final apiUsageAsync = ref.watch(apiUsageCountProvider);
|
||||||
|
|
||||||
return allSakeAsync.when(
|
return allSakeAsync.when(
|
||||||
data: (sakes) {
|
data: (sakes) {
|
||||||
final individualSakes = sakes.where((s) => s.itemType == ItemType.sake).toList();
|
final individualSakes = sakes.where((s) => s.itemType == ItemType.sake).toList();
|
||||||
|
|
@ -25,7 +28,17 @@ class ActivityStats extends ConsumerWidget {
|
||||||
}).toSet();
|
}).toSet();
|
||||||
final recordingDays = dates.length;
|
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 appColors = Theme.of(context).extension<AppColors>()!;
|
||||||
|
final apiColor = isExhausted
|
||||||
|
? appColors.error
|
||||||
|
: isLow
|
||||||
|
? Colors.orange
|
||||||
|
: appColors.brandPrimary;
|
||||||
|
|
||||||
// Bento Grid: 総登録数を大カード、お気に入り・撮影日数を小カード2枚横並び
|
// Bento Grid: 総登録数を大カード、お気に入り・撮影日数を小カード2枚横並び
|
||||||
return Column(
|
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
|
# 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
|
# 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.
|
# 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:
|
environment:
|
||||||
sdk: ^3.10.1
|
sdk: ^3.10.1
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue