fix: quotaLockout Provider化・色トークン整備・依存バージョン固定

- quotaLockoutProvider (NotifierProvider) を新規作成し、カメラ・詳細画面で共有
  - camera_analysis_mixin: quotaLockoutTime フィールドを削除、429時にProviderへ設定
  - camera_screen: ref.watch(quotaLockoutProvider) でシャッターボタンUI更新
  - sake_detail_screen: _quotaLockoutTime フィールドを削除、Providerに移行
  - 画面遷移後もロックアウト状態が保持されるP1バグを解消
- camera_screen: Colors.red/grey → appColors.error/textTertiary に置換
- camera_screen: ギャラリー保存SnackBarから例外文字列 $e を除去
- camera_screen: SnackBarAction textColor Colors.yellow → appColors.brandAccent
- pubspec.yaml: flutter_riverpod ^3.1.0, riverpod_annotation 3.0.0-dev.3,
  riverpod_generator 3.0.0-dev.11 を固定(バージョン未固定による意図しないアップグレードを防止)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Ponshu Developer 2026-04-18 14:18:27 +09:00
parent dd9b814174
commit 9fba57621a
5 changed files with 40 additions and 22 deletions

View File

@ -0,0 +1,13 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
class QuotaLockoutNotifier extends Notifier<DateTime?> {
@override
DateTime? build() => null;
void set(DateTime? value) => state = value;
}
/// Gemini API 429
///
///
/// null = DateTime =
final quotaLockoutProvider = NotifierProvider<QuotaLockoutNotifier, DateTime?>(QuotaLockoutNotifier.new);

View File

@ -6,6 +6,7 @@ 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 '../providers/quota_lockout_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/api_usage_service.dart';
@ -24,7 +25,6 @@ import '../widgets/analyzing_dialog.dart';
/// - _performSakenowaMatching() : /// - _performSakenowaMatching() :
mixin CameraAnalysisMixin<T extends ConsumerStatefulWidget> on ConsumerState<T> { mixin CameraAnalysisMixin<T extends ConsumerStatefulWidget> on ConsumerState<T> {
final List<String> capturedImages = []; final List<String> capturedImages = [];
DateTime? quotaLockoutTime;
Future<void> analyzeImages() async { Future<void> analyzeImages() async {
if (capturedImages.isEmpty) return; if (capturedImages.isEmpty) return;
@ -331,6 +331,7 @@ mixin CameraAnalysisMixin<T extends ConsumerStatefulWidget> on ConsumerState<T>
if (errStr.contains('Quota') || errStr.contains('429')) { if (errStr.contains('Quota') || errStr.contains('429')) {
try { try {
await DraftService.saveDraft(capturedImages, reason: DraftReason.quotaLimit); await DraftService.saveDraft(capturedImages, reason: DraftReason.quotaLimit);
ref.read(quotaLockoutProvider.notifier).set(DateTime.now().add(const Duration(minutes: 1)));
if (!mounted) return; if (!mounted) return;
navigator.pop(); // navigator.pop(); //
final resetTime = ApiUsageService.getNextResetTime(); final resetTime = ApiUsageService.getNextResetTime();

View File

@ -10,6 +10,7 @@ import 'package:gal/gal.dart';
import 'package:lucide_icons/lucide_icons.dart'; import 'package:lucide_icons/lucide_icons.dart';
import 'package:image_picker/image_picker.dart'; // Gallery & Camera import 'package:image_picker/image_picker.dart'; // Gallery & Camera
import '../providers/quota_lockout_provider.dart';
import '../services/image_compression_service.dart'; import '../services/image_compression_service.dart';
import '../theme/app_colors.dart'; import '../theme/app_colors.dart';
import 'camera_analysis_mixin.dart'; import 'camera_analysis_mixin.dart';
@ -165,10 +166,11 @@ class _CameraScreenState extends ConsumerState<CameraScreen> with SingleTickerPr
Future<void> _takePicture() async { Future<void> _takePicture() async {
// Check Quota Lockout // Check Quota Lockout
if (quotaLockoutTime != null) { final quotaLockout = ref.read(quotaLockoutProvider);
final remaining = quotaLockoutTime!.difference(DateTime.now()); if (quotaLockout != null) {
final remaining = quotaLockout.difference(DateTime.now());
if (remaining.isNegative) { if (remaining.isNegative) {
setState(() => quotaLockoutTime = null); // Reset ref.read(quotaLockoutProvider.notifier).set(null);
} else { } else {
final appColors = Theme.of(context).extension<AppColors>()!; final appColors = Theme.of(context).extension<AppColors>()!;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
@ -223,7 +225,7 @@ class _CameraScreenState extends ConsumerState<CameraScreen> with SingleTickerPr
final appColors = Theme.of(context).extension<AppColors>()!; final appColors = Theme.of(context).extension<AppColors>()!;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text('ギャラリー保存に失敗しました: $e'), content: const Text('ギャラリーへの保存に失敗しました'),
duration: const Duration(seconds: 4), duration: const Duration(seconds: 4),
backgroundColor: appColors.warning, backgroundColor: appColors.warning,
), ),
@ -301,6 +303,7 @@ class _CameraScreenState extends ConsumerState<CameraScreen> with SingleTickerPr
// Batch handle - Notification only // Batch handle - Notification only
if (mounted) { if (mounted) {
final appColors = Theme.of(context).extension<AppColors>()!;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text('${images.length}枚の画像を読み込みました。\n右下のボタンから解析を開始してください。'), content: Text('${images.length}枚の画像を読み込みました。\n右下のボタンから解析を開始してください。'),
@ -308,7 +311,7 @@ class _CameraScreenState extends ConsumerState<CameraScreen> with SingleTickerPr
action: SnackBarAction( action: SnackBarAction(
label: '解析する', label: '解析する',
onPressed: analyzeImages, onPressed: analyzeImages,
textColor: Colors.yellow, textColor: appColors.brandAccent,
), ),
), ),
); );
@ -367,6 +370,8 @@ class _CameraScreenState extends ConsumerState<CameraScreen> with SingleTickerPr
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final quotaLockout = ref.watch(quotaLockoutProvider);
final appColors = Theme.of(context).extension<AppColors>()!;
if (_cameraError != null) { if (_cameraError != null) {
return Scaffold( return Scaffold(
backgroundColor: Colors.black, backgroundColor: Colors.black,
@ -564,22 +569,22 @@ class _CameraScreenState extends ConsumerState<CameraScreen> with SingleTickerPr
decoration: BoxDecoration( decoration: BoxDecoration(
shape: BoxShape.circle, shape: BoxShape.circle,
border: Border.all( border: Border.all(
color: quotaLockoutTime != null ? Colors.red : Colors.white, color: quotaLockout != null ? appColors.error : Colors.white,
width: 4 width: 4
), ),
color: _isTakingPicture color: _isTakingPicture
? Colors.white.withValues(alpha: 0.5) ? Colors.white.withValues(alpha: 0.5)
: (quotaLockoutTime != null ? Colors.red.withValues(alpha: 0.2) : Colors.transparent), : (quotaLockout != null ? appColors.error.withValues(alpha: 0.2) : Colors.transparent),
), ),
child: Center( child: Center(
child: quotaLockoutTime != null child: quotaLockout != null
? const Icon(LucideIcons.timer, color: Colors.white, size: 30) ? const Icon(LucideIcons.timer, color: Colors.white, size: 30)
: Container( : Container(
height: 60, height: 60,
width: 60, width: 60,
decoration: BoxDecoration( decoration: BoxDecoration(
shape: BoxShape.circle, shape: BoxShape.circle,
color: quotaLockoutTime != null ? Colors.grey : Colors.white, color: quotaLockout != null ? appColors.textTertiary : Colors.white,
), ),
), ),
), ),

View File

@ -19,6 +19,7 @@ import '../widgets/sake_detail/sake_detail_specs.dart';
import 'sake_detail/widgets/sake_photo_edit_modal.dart'; import 'sake_detail/widgets/sake_photo_edit_modal.dart';
import 'sake_detail/sections/sake_mbti_stamp_section.dart'; import 'sake_detail/sections/sake_mbti_stamp_section.dart';
import '../providers/license_provider.dart'; import '../providers/license_provider.dart';
import '../providers/quota_lockout_provider.dart';
import 'sake_detail/sections/sake_basic_info_section.dart'; import 'sake_detail/sections/sake_basic_info_section.dart';
import 'sake_detail/widgets/sake_detail_sliver_app_bar.dart'; import 'sake_detail/widgets/sake_detail_sliver_app_bar.dart';
import '../services/mbti_compatibility_service.dart'; import '../services/mbti_compatibility_service.dart';
@ -39,7 +40,6 @@ class _SakeDetailScreenState extends ConsumerState<SakeDetailScreen> {
late SakeItem _sake; late SakeItem _sake;
int _currentImageIndex = 0; int _currentImageIndex = 0;
bool _isAnalyzing = false; bool _isAnalyzing = false;
DateTime? _quotaLockoutTime;
@override @override
@ -303,10 +303,11 @@ class _SakeDetailScreenState extends ConsumerState<SakeDetailScreen> {
if (_isAnalyzing) return; if (_isAnalyzing) return;
// 2. Check Quota Lockout // 2. Check Quota Lockout
if (_quotaLockoutTime != null) { final quotaLockout = ref.read(quotaLockoutProvider);
final remaining = _quotaLockoutTime!.difference(DateTime.now()); if (quotaLockout != null) {
final remaining = quotaLockout.difference(DateTime.now());
if (remaining.isNegative) { if (remaining.isNegative) {
setState(() => _quotaLockoutTime = null); // Reset if time passed ref.read(quotaLockoutProvider.notifier).set(null);
} else { } else {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('AI利用制限中です。あと${remaining.inSeconds}秒お待ちください。')), SnackBar(content: Text('AI利用制限中です。あと${remaining.inSeconds}秒お待ちください。')),
@ -389,9 +390,7 @@ class _SakeDetailScreenState extends ConsumerState<SakeDetailScreen> {
// Check for Quota Error to set Lockout // Check for Quota Error to set Lockout
if (e.toString().contains('Quota') || e.toString().contains('429')) { if (e.toString().contains('Quota') || e.toString().contains('429')) {
setState(() { ref.read(quotaLockoutProvider.notifier).set(DateTime.now().add(const Duration(minutes: 1)));
_quotaLockoutTime = DateTime.now().add(const Duration(minutes: 1));
});
} }
debugPrint('Reanalyze error: $e'); debugPrint('Reanalyze error: $e');

View File

@ -35,8 +35,8 @@ dependencies:
# Use with the CupertinoIcons class for iOS style icons. # Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^1.0.8 cupertino_icons: ^1.0.8
google_fonts: ^6.3.3 google_fonts: ^6.3.3
flutter_riverpod: flutter_riverpod: ^3.1.0
riverpod_annotation: riverpod_annotation: 3.0.0-dev.3
hive: ^2.2.3 hive: ^2.2.3
hive_flutter: ^1.1.0 hive_flutter: ^1.1.0
google_generative_ai: ^0.4.7 google_generative_ai: ^0.4.7
@ -85,7 +85,7 @@ dev_dependencies:
flutter_lints: ^6.0.0 flutter_lints: ^6.0.0
build_runner: build_runner:
hive_generator: hive_generator:
riverpod_generator: riverpod_generator: 3.0.0-dev.11
flutter_launcher_icons: ^0.13.1 flutter_launcher_icons: ^0.13.1
flutter_native_splash: ^2.4.4 flutter_native_splash: ^2.4.4