Compare commits
7 Commits
5d8689b7ee
...
d72587ac19
| Author | SHA1 | Date |
|---|---|---|
|
|
d72587ac19 | |
|
|
1a84163654 | |
|
|
2e770ff98d | |
|
|
e7bb4e494c | |
|
|
5bcacfffa3 | |
|
|
9fba57621a | |
|
|
dd9b814174 |
|
|
@ -28,5 +28,5 @@ GITEA_REPO=ponshu_room_lite
|
||||||
# VERCEL_PROJECT_ID=prj_xxxxxxxxxx
|
# VERCEL_PROJECT_ID=prj_xxxxxxxxxx
|
||||||
|
|
||||||
# APKビルド設定
|
# APKビルド設定
|
||||||
MAITA_API_KEY=AIzaSyDjPZGOHy-xAstpLks081SIbUdTyb_iJpU
|
MAITA_API_KEY=AIzaSy_YOUR_GEMINI_KEY_HERE
|
||||||
EIJI_API_KEY=AIzaSyBEwmTa9_2aiRrwr1mXE7Qriw8mIg1xr0U
|
EIJI_API_KEY=AIzaSy_YOUR_GEMINI_KEY_HERE
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -32,6 +32,7 @@ mixin CameraAnalysisMixin<T extends ConsumerStatefulWidget> on ConsumerState<T>
|
||||||
// async gap 前に context 依存オブジェクトをキャプチャ
|
// async gap 前に context 依存オブジェクトをキャプチャ
|
||||||
final messenger = ScaffoldMessenger.of(context);
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
final navigator = Navigator.of(context);
|
final navigator = Navigator.of(context);
|
||||||
|
final appColors = Theme.of(context).extension<AppColors>()!;
|
||||||
|
|
||||||
final isOnline = await NetworkService.isOnline();
|
final isOnline = await NetworkService.isOnline();
|
||||||
if (!isOnline) {
|
if (!isOnline) {
|
||||||
|
|
@ -44,25 +45,25 @@ mixin CameraAnalysisMixin<T extends ConsumerStatefulWidget> on ConsumerState<T>
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
messenger.showSnackBar(
|
messenger.showSnackBar(
|
||||||
const SnackBar(
|
SnackBar(
|
||||||
content: Column(
|
content: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(LucideIcons.wifiOff, color: Colors.orange, size: 16),
|
Icon(LucideIcons.wifiOff, color: Colors.white, size: 16),
|
||||||
SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Text('オフライン検知', style: TextStyle(fontWeight: FontWeight.bold)),
|
const Text('オフライン検知', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text('写真を「解析待ち」として保存しました。'),
|
const Text('写真を「解析待ち」として保存しました。'),
|
||||||
Text('オンライン復帰後、ホーム画面から解析できます。'),
|
const Text('オンライン復帰後、ホーム画面から解析できます。'),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
duration: Duration(seconds: 5),
|
duration: const Duration(seconds: 5),
|
||||||
backgroundColor: Colors.orange,
|
backgroundColor: appColors.warning,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -72,7 +73,7 @@ mixin CameraAnalysisMixin<T extends ConsumerStatefulWidget> on ConsumerState<T>
|
||||||
debugPrint('Draft save error: $e');
|
debugPrint('Draft save error: $e');
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
messenger.showSnackBar(
|
messenger.showSnackBar(
|
||||||
SnackBar(content: Text('Draft保存エラー: $e')),
|
const SnackBar(content: Text('写真の一時保存に失敗しました。再度お試しください。')),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -93,11 +94,11 @@ mixin CameraAnalysisMixin<T extends ConsumerStatefulWidget> on ConsumerState<T>
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
const Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(LucideIcons.zap, color: Colors.orange, size: 16),
|
const Icon(LucideIcons.zap, color: Colors.white, size: 16),
|
||||||
SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Text('本日のAI解析上限(20回)に達しました',
|
const Text('本日のAI解析上限(20回)に達しました',
|
||||||
style: TextStyle(fontWeight: FontWeight.bold)),
|
style: TextStyle(fontWeight: FontWeight.bold)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
@ -107,7 +108,7 @@ mixin CameraAnalysisMixin<T extends ConsumerStatefulWidget> on ConsumerState<T>
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
duration: const Duration(seconds: 6),
|
duration: const Duration(seconds: 6),
|
||||||
backgroundColor: Colors.orange,
|
backgroundColor: appColors.warning,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
navigator.pop(); // カメラ画面を閉じてホームへ
|
navigator.pop(); // カメラ画面を閉じてホームへ
|
||||||
|
|
@ -115,7 +116,7 @@ mixin CameraAnalysisMixin<T extends ConsumerStatefulWidget> on ConsumerState<T>
|
||||||
debugPrint('Draft save error (quota): $e');
|
debugPrint('Draft save error (quota): $e');
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
messenger.showSnackBar(
|
messenger.showSnackBar(
|
||||||
SnackBar(content: Text('保存エラー: $e')),
|
const SnackBar(content: Text('写真の一時保存に失敗しました。再度お試しください。')),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
|
|
@ -224,30 +225,27 @@ mixin CameraAnalysisMixin<T extends ConsumerStatefulWidget> on ConsumerState<T>
|
||||||
navigator.pop(); // Close Camera Screen (Return to Home)
|
navigator.pop(); // Close Camera Screen (Return to Home)
|
||||||
|
|
||||||
// Success Message
|
// Success Message
|
||||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
|
||||||
final List<Widget> messageWidgets = [
|
final List<Widget> messageWidgets = [
|
||||||
Text('${sakeItem.displayData.displayName} を登録しました!'),
|
Text('${sakeItem.displayData.displayName} を登録しました!'),
|
||||||
];
|
];
|
||||||
|
|
||||||
if (result.isFromCache) {
|
if (result.isFromCache) {
|
||||||
messageWidgets.add(const SizedBox(height: 4));
|
messageWidgets.add(const SizedBox(height: 4));
|
||||||
messageWidgets.add(const Text(
|
messageWidgets.add(Text(
|
||||||
'※ 解析済みの結果を使用(経験値なし)',
|
'※ 解析済みの結果を使用(経験値なし)',
|
||||||
style: TextStyle(fontSize: 12, color: Colors.grey),
|
style: TextStyle(fontSize: 12, color: appColors.textTertiary),
|
||||||
));
|
));
|
||||||
} else {
|
} else {
|
||||||
messageWidgets.add(const SizedBox(height: 4));
|
messageWidgets.add(const SizedBox(height: 4));
|
||||||
messageWidgets.add(Row(
|
messageWidgets.add(Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(LucideIcons.sparkles,
|
Icon(LucideIcons.sparkles, color: appColors.brandAccent, size: 16),
|
||||||
color: isDark ? Colors.yellow.shade300 : Colors.yellow,
|
|
||||||
size: 16),
|
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Text(
|
Text(
|
||||||
'経験値 +$expGained GET!${isLevelUp ? " (Level UP!)" : ""}',
|
'経験値 +$expGained GET!${isLevelUp ? " (Level UP!)" : ""}',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
color: isDark ? Colors.yellow.shade200 : Colors.yellowAccent,
|
color: appColors.brandAccent,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
@ -266,7 +264,7 @@ mixin CameraAnalysisMixin<T extends ConsumerStatefulWidget> on ConsumerState<T>
|
||||||
'バッジ獲得: ${badge.name}',
|
'バッジ獲得: ${badge.name}',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
color: isDark ? Colors.green.shade300 : Colors.greenAccent,
|
color: appColors.success,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
@ -297,25 +295,25 @@ mixin CameraAnalysisMixin<T extends ConsumerStatefulWidget> on ConsumerState<T>
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
navigator.pop(); // Close camera screen
|
navigator.pop(); // Close camera screen
|
||||||
messenger.showSnackBar(
|
messenger.showSnackBar(
|
||||||
const SnackBar(
|
SnackBar(
|
||||||
content: Column(
|
content: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Row(
|
const Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(LucideIcons.cloudOff, color: Colors.white, size: 16),
|
Icon(LucideIcons.cloudOff, color: Colors.white, size: 16),
|
||||||
SizedBox(width: 8),
|
SizedBox(width: 8),
|
||||||
Text('AIサーバー混雑', style: TextStyle(fontWeight: FontWeight.bold)),
|
Text('AIサーバー混雑', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text('写真を「解析待ち」として保存しました。'),
|
const Text('写真を「解析待ち」として保存しました。'),
|
||||||
Text('時間をおいてホーム画面から解析できます。'),
|
const Text('時間をおいてホーム画面から解析できます。'),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
duration: Duration(seconds: 5),
|
duration: const Duration(seconds: 5),
|
||||||
backgroundColor: Colors.orange,
|
backgroundColor: appColors.warning,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
} catch (draftError) {
|
} catch (draftError) {
|
||||||
|
|
@ -333,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();
|
||||||
|
|
@ -345,7 +344,7 @@ mixin CameraAnalysisMixin<T extends ConsumerStatefulWidget> on ConsumerState<T>
|
||||||
children: [
|
children: [
|
||||||
const Row(
|
const Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(LucideIcons.zap, color: Colors.orange, size: 16),
|
Icon(LucideIcons.zap, color: Colors.white, size: 16),
|
||||||
SizedBox(width: 8),
|
SizedBox(width: 8),
|
||||||
Text('本日のAI解析上限(20回)に達しました',
|
Text('本日のAI解析上限(20回)に達しました',
|
||||||
style: TextStyle(fontWeight: FontWeight.bold)),
|
style: TextStyle(fontWeight: FontWeight.bold)),
|
||||||
|
|
@ -357,7 +356,7 @@ mixin CameraAnalysisMixin<T extends ConsumerStatefulWidget> on ConsumerState<T>
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
duration: const Duration(seconds: 6),
|
duration: const Duration(seconds: 6),
|
||||||
backgroundColor: Colors.orange,
|
backgroundColor: appColors.warning,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
} catch (draftError) {
|
} catch (draftError) {
|
||||||
|
|
@ -370,10 +369,10 @@ mixin CameraAnalysisMixin<T extends ConsumerStatefulWidget> on ConsumerState<T>
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final appColors = Theme.of(context).extension<AppColors>()!;
|
debugPrint('Analysis error: $e');
|
||||||
messenger.showSnackBar(
|
messenger.showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text('解析エラー: $e'),
|
content: const Text('解析に失敗しました。時間をおいて再試行してください。'),
|
||||||
duration: const Duration(seconds: 5),
|
duration: const Duration(seconds: 5),
|
||||||
backgroundColor: appColors.error,
|
backgroundColor: appColors.error,
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -591,7 +596,7 @@ class _CameraScreenState extends ConsumerState<CameraScreen> with SingleTickerPr
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: Badge(
|
icon: Badge(
|
||||||
label: Text('${capturedImages.length}'),
|
label: Text('${capturedImages.length}'),
|
||||||
child: const Icon(LucideIcons.playCircle, color: Colors.greenAccent, size: 40),
|
child: Icon(LucideIcons.playCircle, color: appColors.success, size: 40),
|
||||||
),
|
),
|
||||||
onPressed: analyzeImages,
|
onPressed: analyzeImages,
|
||||||
tooltip: '解析を開始',
|
tooltip: '解析を開始',
|
||||||
|
|
|
||||||
|
|
@ -58,8 +58,9 @@ class _SommelierScreenState extends ConsumerState<SommelierScreen> {
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
|
debugPrint('Share error: $e');
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(content: Text('シェアに失敗しました: $e')),
|
const SnackBar(content: Text('シェアに失敗しました。再度お試しください。')),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -541,7 +542,7 @@ class _SommelierScreenState extends ConsumerState<SommelierScreen> {
|
||||||
debugPrint('Diagnosis Error: $e');
|
debugPrint('Diagnosis Error: $e');
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
navigator.pop();
|
navigator.pop();
|
||||||
messenger.showSnackBar(SnackBar(content: Text('エラー: $e')));
|
messenger.showSnackBar(const SnackBar(content: Text('診断に失敗しました。時間をおいて再試行してください。')));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -95,7 +95,7 @@ class PdfPreviewScreen extends ConsumerWidget {
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
loadingWidget: const Center(child: CircularProgressIndicator()),
|
loadingWidget: const Center(child: CircularProgressIndicator()),
|
||||||
onError: (context, error) => Center(child: Text('エラーが発生しました: $error')),
|
onError: (context, error) => const Center(child: Text('PDFの表示に失敗しました')),
|
||||||
),
|
),
|
||||||
bottomNavigationBar: SafeArea(
|
bottomNavigationBar: SafeArea(
|
||||||
child: Column(
|
child: Column(
|
||||||
|
|
@ -246,9 +246,10 @@ class PdfPreviewScreen extends ConsumerWidget {
|
||||||
filename: 'oshinagaki_${DateTime.now().toString().split(' ')[0]}.pdf',
|
filename: 'oshinagaki_${DateTime.now().toString().split(' ')[0]}.pdf',
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
debugPrint('PDF share error: $e');
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(content: Text('共有エラー: $e')),
|
const SnackBar(content: Text('PDFの共有に失敗しました。再度お試しください。')),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -284,7 +285,7 @@ class PdfPreviewScreen extends ConsumerWidget {
|
||||||
..name = fileName
|
..name = fileName
|
||||||
..mimeType = 'application/pdf';
|
..mimeType = 'application/pdf';
|
||||||
|
|
||||||
debugPrint('[PDF_DRIVE] 📤 アップロード開始: $fileName (${bytes.length} bytes)');
|
debugPrint('[PDF_DRIVE] Upload start: $fileName (${bytes.length} bytes)');
|
||||||
final uploadedFile = await driveApi.files.create(
|
final uploadedFile = await driveApi.files.create(
|
||||||
driveFile,
|
driveFile,
|
||||||
uploadMedia: drive.Media(
|
uploadMedia: drive.Media(
|
||||||
|
|
@ -294,11 +295,11 @@ class PdfPreviewScreen extends ConsumerWidget {
|
||||||
);
|
);
|
||||||
|
|
||||||
if (uploadedFile.id == null) {
|
if (uploadedFile.id == null) {
|
||||||
debugPrint('[PDF_DRIVE] ❌ アップロード失敗: ID取得不可');
|
debugPrint('[PDF_DRIVE] Upload failed: no file ID returned');
|
||||||
throw Exception('アップロードに失敗しました(IDなし)');
|
throw Exception('アップロードに失敗しました(IDなし)');
|
||||||
}
|
}
|
||||||
|
|
||||||
debugPrint('[PDF_DRIVE] ✅ アップロード完了: ID=${uploadedFile.id}');
|
debugPrint('[PDF_DRIVE] Upload complete: ID=${uploadedFile.id}');
|
||||||
|
|
||||||
// 5. Success notification
|
// 5. Success notification
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
|
|
@ -314,11 +315,12 @@ class PdfPreviewScreen extends ConsumerWidget {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
debugPrint('Drive upload error: $e');
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
const SnackBar(
|
||||||
content: Text('Driveアップロードエラー: $e'),
|
content: Text('Google Driveへの保存に失敗しました。再度お試しください。'),
|
||||||
duration: const Duration(seconds: 4),
|
duration: Duration(seconds: 4),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -336,9 +338,10 @@ class PdfPreviewScreen extends ConsumerWidget {
|
||||||
format: _getPageFormat(pdfSize, ref.read(pdfIsPortraitProvider)),
|
format: _getPageFormat(pdfSize, ref.read(pdfIsPortraitProvider)),
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
debugPrint('Print error: $e');
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(content: Text('印刷エラー: $e')),
|
const SnackBar(content: Text('印刷に失敗しました。再度お試しください。')),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -194,10 +194,11 @@ class _PendingAnalysisScreenState extends ConsumerState<PendingAnalysisScreen> {
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
debugPrint('Draft delete error: $e');
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
const SnackBar(
|
||||||
content: Text('削除エラー: $e'),
|
content: Text('削除に失敗しました。再度お試しください。'),
|
||||||
duration: const Duration(seconds: 5),
|
duration: Duration(seconds: 5),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -101,7 +101,7 @@ class SakeBasicInfoSection extends ConsumerWidget {
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: badgeColor!.withValues(alpha: 0.1),
|
color: badgeColor!.withValues(alpha: 0.1),
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
border: Border.all(color: badgeColor!.withValues(alpha: 0.4)),
|
border: Border.all(color: badgeColor.withValues(alpha: 0.4)),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
|
@ -109,7 +109,7 @@ class SakeBasicInfoSection extends ConsumerWidget {
|
||||||
Icon(LucideIcons.brainCircuit, size: 12, color: badgeColor),
|
Icon(LucideIcons.brainCircuit, size: 12, color: badgeColor),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
Text(
|
Text(
|
||||||
'$mbtiType ${mbtiResult!.starDisplay}',
|
'$mbtiType ${mbtiResult.starDisplay}',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: badgeColor,
|
color: badgeColor,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
|
|
|
||||||
|
|
@ -234,9 +234,10 @@ class _SakePhotoEditModalState extends State<SakePhotoEditModal> {
|
||||||
await _saveNewPhoto(savedPath);
|
await _saveNewPhoto(savedPath);
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
debugPrint('Photo pick error: $e');
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(content: Text('エラー: $e')),
|
const SnackBar(content: Text('写真の追加に失敗しました。再度お試しください。')),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
@ -36,10 +37,9 @@ class SakeDetailScreen extends ConsumerStatefulWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
class _SakeDetailScreenState extends ConsumerState<SakeDetailScreen> {
|
class _SakeDetailScreenState extends ConsumerState<SakeDetailScreen> {
|
||||||
// To trigger rebuilds if we don't switch to a stream
|
|
||||||
late SakeItem _sake;
|
late SakeItem _sake;
|
||||||
int _currentImageIndex = 0;
|
int _currentImageIndex = 0;
|
||||||
// Memo logic moved to SakeDetailMemo
|
bool _isAnalyzing = false;
|
||||||
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -119,15 +119,12 @@ class _SakeDetailScreenState extends ConsumerState<SakeDetailScreen> {
|
||||||
gradient: LinearGradient(
|
gradient: LinearGradient(
|
||||||
begin: Alignment.topCenter,
|
begin: Alignment.topCenter,
|
||||||
end: Alignment.bottomCenter,
|
end: Alignment.bottomCenter,
|
||||||
colors: Theme.of(context).brightness == Brightness.dark
|
colors: [
|
||||||
? [
|
Theme.of(context).scaffoldBackgroundColor,
|
||||||
const Color(0xFF121212), // Scaffold Background
|
Theme.of(context).brightness == Brightness.dark
|
||||||
const Color(0xFF1E1E1E), // Slightly lighter surface
|
? appColors.brandSurface
|
||||||
]
|
: Theme.of(context).primaryColor.withValues(alpha: 0.05),
|
||||||
: [
|
],
|
||||||
Theme.of(context).scaffoldBackgroundColor,
|
|
||||||
Theme.of(context).primaryColor.withValues(alpha: 0.05),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
padding: const EdgeInsets.all(24.0),
|
padding: const EdgeInsets.all(24.0),
|
||||||
|
|
@ -281,10 +278,6 @@ class _SakeDetailScreenState extends ConsumerState<SakeDetailScreen> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
bool _isAnalyzing = false;
|
|
||||||
DateTime? _quotaLockoutTime;
|
|
||||||
|
|
||||||
|
|
||||||
Future<void> _toggleFavorite() async {
|
Future<void> _toggleFavorite() async {
|
||||||
HapticFeedback.mediumImpact();
|
HapticFeedback.mediumImpact();
|
||||||
final box = Hive.box<SakeItem>('sake_items');
|
final box = Hive.box<SakeItem>('sake_items');
|
||||||
|
|
@ -309,13 +302,18 @@ class _SakeDetailScreenState extends ConsumerState<SakeDetailScreen> {
|
||||||
// 1. Check Locks
|
// 1. Check Locks
|
||||||
if (_isAnalyzing) return;
|
if (_isAnalyzing) return;
|
||||||
|
|
||||||
|
// async gap 前に context 依存オブジェクトをキャプチャ
|
||||||
|
final nav = Navigator.of(context);
|
||||||
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
|
|
||||||
// 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(
|
messenger.showSnackBar(
|
||||||
SnackBar(content: Text('AI利用制限中です。あと${remaining.inSeconds}秒お待ちください。')),
|
SnackBar(content: Text('AI利用制限中です。あと${remaining.inSeconds}秒お待ちください。')),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
|
|
@ -332,8 +330,6 @@ class _SakeDetailScreenState extends ConsumerState<SakeDetailScreen> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
final nav = Navigator.of(context);
|
|
||||||
final messenger = ScaffoldMessenger.of(context);
|
|
||||||
|
|
||||||
if (existingPaths.isEmpty) {
|
if (existingPaths.isEmpty) {
|
||||||
messenger.showSnackBar(
|
messenger.showSnackBar(
|
||||||
|
|
@ -345,13 +341,8 @@ class _SakeDetailScreenState extends ConsumerState<SakeDetailScreen> {
|
||||||
setState(() => _isAnalyzing = true);
|
setState(() => _isAnalyzing = true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// ignore: use_build_context_synchronously
|
// ignore: use_build_context_synchronously
|
||||||
// mounted チェック済み(334行目)かつ await なしで呼び出すため安全
|
showDialog(context: context, barrierDismissible: false, builder: (context) => const AnalyzingDialog());
|
||||||
showDialog(
|
|
||||||
context: context,
|
|
||||||
barrierDismissible: false,
|
|
||||||
builder: (context) => const AnalyzingDialog(),
|
|
||||||
);
|
|
||||||
|
|
||||||
final geminiService = ref.read(geminiServiceProvider);
|
final geminiService = ref.read(geminiServiceProvider);
|
||||||
final result = await geminiService.analyzeSakeLabel(existingPaths, forceRefresh: true);
|
final result = await geminiService.analyzeSakeLabel(existingPaths, forceRefresh: true);
|
||||||
|
|
@ -396,13 +387,12 @@ 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');
|
||||||
messenger.showSnackBar(
|
messenger.showSnackBar(
|
||||||
SnackBar(content: Text('エラー: $e')),
|
const SnackBar(content: Text('再解析に失敗しました。時間をおいて再試行してください。')),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -413,11 +403,11 @@ class _SakeDetailScreenState extends ConsumerState<SakeDetailScreen> {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
void _showTagEditDialog(BuildContext context) {
|
Future<void> _showTagEditDialog(BuildContext context) async {
|
||||||
final TextEditingController tagController = TextEditingController();
|
final TextEditingController tagController = TextEditingController();
|
||||||
final allTags = _sake.hiddenSpecs.flavorTags.toSet();
|
final allTags = _sake.hiddenSpecs.flavorTags.toSet();
|
||||||
|
try {
|
||||||
showDialog(
|
await showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (ctx) => StatefulBuilder(
|
builder: (ctx) => StatefulBuilder(
|
||||||
builder: (context, setModalState) {
|
builder: (context, setModalState) {
|
||||||
|
|
@ -498,6 +488,9 @@ class _SakeDetailScreenState extends ConsumerState<SakeDetailScreen> {
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
} finally {
|
||||||
|
tagController.dispose();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _updateTags(List<String> newTags) async {
|
Future<void> _updateTags(List<String> newTags) async {
|
||||||
|
|
@ -581,33 +574,37 @@ class _SakeDetailScreenState extends ConsumerState<SakeDetailScreen> {
|
||||||
required Future<void> Function(String) onSave,
|
required Future<void> Function(String) onSave,
|
||||||
}) async {
|
}) async {
|
||||||
final controller = TextEditingController(text: initialValue);
|
final controller = TextEditingController(text: initialValue);
|
||||||
await showDialog(
|
try {
|
||||||
context: context,
|
await showDialog(
|
||||||
builder: (context) => AlertDialog(
|
context: context,
|
||||||
title: Text(title),
|
builder: (context) => AlertDialog(
|
||||||
content: TextField(
|
title: Text(title),
|
||||||
controller: controller,
|
content: TextField(
|
||||||
decoration: const InputDecoration(
|
controller: controller,
|
||||||
border: OutlineInputBorder(),
|
decoration: const InputDecoration(
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
autofocus: true,
|
||||||
|
maxLines: null,
|
||||||
),
|
),
|
||||||
autofocus: true,
|
actions: [
|
||||||
maxLines: null,
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: const Text('キャンセル'),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () async {
|
||||||
|
await onSave(controller.text);
|
||||||
|
if (context.mounted) Navigator.pop(context);
|
||||||
|
},
|
||||||
|
child: const Text('保存'),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
actions: [
|
);
|
||||||
TextButton(
|
} finally {
|
||||||
onPressed: () => Navigator.pop(context),
|
controller.dispose();
|
||||||
child: const Text('キャンセル'),
|
}
|
||||||
),
|
|
||||||
ElevatedButton(
|
|
||||||
onPressed: () async {
|
|
||||||
await onSave(controller.text);
|
|
||||||
if (context.mounted) Navigator.pop(context);
|
|
||||||
},
|
|
||||||
child: const Text('保存'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// MBTI相性詳細ダイアログを表示
|
/// MBTI相性詳細ダイアログを表示
|
||||||
|
|
@ -732,52 +729,57 @@ class _SakeDetailScreenState extends ConsumerState<SakeDetailScreen> {
|
||||||
Future<void> _showBreweryEditDialog(BuildContext context) async {
|
Future<void> _showBreweryEditDialog(BuildContext context) async {
|
||||||
final breweryController = TextEditingController(text: _sake.displayData.displayBrewery);
|
final breweryController = TextEditingController(text: _sake.displayData.displayBrewery);
|
||||||
final prefectureController = TextEditingController(text: _sake.displayData.displayPrefecture);
|
final prefectureController = TextEditingController(text: _sake.displayData.displayPrefecture);
|
||||||
await showDialog(
|
try {
|
||||||
context: context,
|
await showDialog(
|
||||||
builder: (context) => AlertDialog(
|
context: context,
|
||||||
title: const Text('酒蔵・都道府県を編集'),
|
builder: (context) => AlertDialog(
|
||||||
content: Column(
|
title: const Text('酒蔵・都道府県を編集'),
|
||||||
mainAxisSize: MainAxisSize.min,
|
content: Column(
|
||||||
children: [
|
mainAxisSize: MainAxisSize.min,
|
||||||
TextField(
|
children: [
|
||||||
controller: breweryController,
|
TextField(
|
||||||
decoration: const InputDecoration(
|
controller: breweryController,
|
||||||
labelText: '酒蔵',
|
decoration: const InputDecoration(
|
||||||
border: OutlineInputBorder(),
|
labelText: '酒蔵',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextField(
|
||||||
|
controller: prefectureController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: '都道府県',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: const Text('キャンセル'),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
ElevatedButton(
|
||||||
TextField(
|
onPressed: () async {
|
||||||
controller: prefectureController,
|
final box = Hive.box<SakeItem>('sake_items');
|
||||||
decoration: const InputDecoration(
|
final updated = _sake.copyWith(
|
||||||
labelText: '都道府県',
|
brand: breweryController.text,
|
||||||
border: OutlineInputBorder(),
|
prefecture: prefectureController.text,
|
||||||
),
|
isUserEdited: true,
|
||||||
|
);
|
||||||
|
await box.put(_sake.key, updated);
|
||||||
|
setState(() => _sake = updated);
|
||||||
|
if (context.mounted) Navigator.pop(context);
|
||||||
|
},
|
||||||
|
child: const Text('保存'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
actions: [
|
);
|
||||||
TextButton(
|
} finally {
|
||||||
onPressed: () => Navigator.pop(context),
|
breweryController.dispose();
|
||||||
child: const Text('キャンセル'),
|
prefectureController.dispose();
|
||||||
),
|
}
|
||||||
ElevatedButton(
|
|
||||||
onPressed: () async {
|
|
||||||
final box = Hive.box<SakeItem>('sake_items');
|
|
||||||
final updated = _sake.copyWith(
|
|
||||||
brand: breweryController.text,
|
|
||||||
prefecture: prefectureController.text,
|
|
||||||
isUserEdited: true,
|
|
||||||
);
|
|
||||||
await box.put(_sake.key, updated);
|
|
||||||
setState(() => _sake = updated);
|
|
||||||
if (context.mounted) Navigator.pop(context);
|
|
||||||
},
|
|
||||||
child: const Text('保存'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 写真編集モーダルを表示
|
/// 写真編集モーダルを表示
|
||||||
|
|
|
||||||
|
|
@ -77,10 +77,10 @@ class _ScanARScreenState extends ConsumerState<ScanARScreen>
|
||||||
_isInitializing = false;
|
_isInitializing = false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
debugPrint('✅ Scanner: Controller created successfully');
|
debugPrint('[Scanner] Controller created successfully');
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('❌ Scanner: Error during initialization: $e');
|
debugPrint('[Scanner] Error during initialization: $e');
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_isInitializing = false;
|
_isInitializing = false;
|
||||||
|
|
@ -395,7 +395,7 @@ class _DigitalSakeCardDialog extends StatelessWidget {
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
'$brewery / $prefecture',
|
'$brewery / $prefecture',
|
||||||
style: const TextStyle(fontSize: 14, color: Colors.grey),
|
style: TextStyle(fontSize: 14, color: appColors.textTertiary),
|
||||||
),
|
),
|
||||||
|
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
|
|
|
||||||
|
|
@ -148,17 +148,22 @@ class LicenseService {
|
||||||
|
|
||||||
if (cached == null) return LicenseStatus.free;
|
if (cached == null) return LicenseStatus.free;
|
||||||
|
|
||||||
// キャッシュが古すぎる場合はfreeにフォールバック
|
// オンライン時は _validateKeyWithServer が常に上書きするため、
|
||||||
// pro と revoked は期限切れにしない(proは購入者を締め出さない、revokedは誤って復活させない)
|
// _getCachedStatus はオフライン時専用のフォールバックとして動作する。
|
||||||
|
//
|
||||||
|
// TTL 判定(_cacheValidSeconds = 24h):
|
||||||
|
// - free / offline は期限切れで free にフォールバック
|
||||||
|
// - pro : 購入者をオフライン時に締め出さないため永続扱い
|
||||||
|
// - revoked: 不正防止を優先するため永続扱い
|
||||||
|
// (将来 TTL を設けたい場合は isNoExpiryStatus を条件分岐ごと差し替える)
|
||||||
if (cachedAt != null) {
|
if (cachedAt != null) {
|
||||||
final age = DateTime.now().difference(DateTime.parse(cachedAt));
|
final age = DateTime.now().difference(DateTime.parse(cachedAt));
|
||||||
final isPermanentStatus = cached == LicenseStatus.pro.name || cached == LicenseStatus.revoked.name;
|
final isNoExpiryStatus = cached == LicenseStatus.pro.name || cached == LicenseStatus.revoked.name;
|
||||||
if (age.inSeconds > _cacheValidSeconds && !isPermanentStatus) {
|
if (age.inSeconds > _cacheValidSeconds && !isNoExpiryStatus) {
|
||||||
return LicenseStatus.free;
|
return LicenseStatus.free;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pro キャッシュはオフラインでも維持(購入者を締め出さない)
|
|
||||||
return LicenseStatus.values.firstWhere(
|
return LicenseStatus.values.firstWhere(
|
||||||
(s) => s.name == cached,
|
(s) => s.name == cached,
|
||||||
orElse: () => LicenseStatus.free,
|
orElse: () => LicenseStatus.free,
|
||||||
|
|
|
||||||
|
|
@ -104,7 +104,7 @@ class _AddSetItemDialogState extends ConsumerState<AddSetItemDialog> {
|
||||||
final newlyUnlockedBadges = await GamificationService.checkAndUnlockBadges(ref);
|
final newlyUnlockedBadges = await GamificationService.checkAndUnlockBadges(ref);
|
||||||
|
|
||||||
if (newlyUnlockedBadges.isNotEmpty) {
|
if (newlyUnlockedBadges.isNotEmpty) {
|
||||||
debugPrint('🏅 Badges Unlocked: ${newlyUnlockedBadges.map((b) => b.name).join(", ")}');
|
debugPrint('[Gamification] Badges Unlocked: ${newlyUnlockedBadges.map((b) => b.name).join(", ")}');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,7 @@ class ActivityStats extends ConsumerWidget {
|
||||||
final apiColor = isExhausted
|
final apiColor = isExhausted
|
||||||
? appColors.error
|
? appColors.error
|
||||||
: isLow
|
: isLow
|
||||||
? Colors.orange
|
? appColors.warning
|
||||||
: appColors.brandPrimary;
|
: appColors.brandPrimary;
|
||||||
|
|
||||||
// Bento Grid: 総登録数を大カード、お気に入り・撮影日数を小カード2枚横並び
|
// Bento Grid: 総登録数を大カード、お気に入り・撮影日数を小カード2枚横並び
|
||||||
|
|
@ -192,7 +192,7 @@ class ActivityStats extends ConsumerWidget {
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
Text(
|
Text(
|
||||||
'残り$remaining回です。',
|
'残り$remaining回です。',
|
||||||
style: const TextStyle(fontSize: 10, color: Colors.orange),
|
style: TextStyle(fontSize: 10, color: appColors.warning),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -2,19 +2,21 @@ import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import '../../providers/filter_providers.dart';
|
import '../../providers/filter_providers.dart';
|
||||||
import 'package:lucide_icons/lucide_icons.dart';
|
import 'package:lucide_icons/lucide_icons.dart';
|
||||||
|
import '../../theme/app_colors.dart';
|
||||||
|
|
||||||
class SakeNoMatchState extends ConsumerWidget {
|
class SakeNoMatchState extends ConsumerWidget {
|
||||||
const SakeNoMatchState({super.key});
|
const SakeNoMatchState({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final appColors = Theme.of(context).extension<AppColors>()!;
|
||||||
return Center(
|
return Center(
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Icon(LucideIcons.filterX, size: 48, color: Colors.grey[400]),
|
Icon(LucideIcons.filterX, size: 48, color: appColors.textTertiary),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text('条件に一致するお酒が見つかりません', style: TextStyle(color: Colors.grey[600])),
|
Text('条件に一致するお酒が見つかりません', style: TextStyle(color: appColors.textTertiary)),
|
||||||
TextButton(
|
TextButton(
|
||||||
child: const Text('フィルタを解除'),
|
child: const Text('フィルタを解除'),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
|
|
|
||||||
|
|
@ -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 '../services/draft_service.dart';
|
import '../services/draft_service.dart';
|
||||||
import '../screens/pending_analysis_screen.dart';
|
import '../screens/pending_analysis_screen.dart';
|
||||||
|
import '../theme/app_colors.dart';
|
||||||
|
|
||||||
/// 未解析Draft(解析待ちアイテム)通知バナー
|
/// 未解析Draft(解析待ちアイテム)通知バナー
|
||||||
///
|
///
|
||||||
|
|
@ -24,19 +25,21 @@ class PendingAnalysisBanner extends ConsumerWidget {
|
||||||
return const SizedBox.shrink();
|
return const SizedBox.shrink();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final appColors = Theme.of(context).extension<AppColors>()!;
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
margin: const EdgeInsets.all(12),
|
margin: const EdgeInsets.all(12),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
gradient: LinearGradient(
|
gradient: LinearGradient(
|
||||||
colors: [
|
colors: [
|
||||||
Colors.orange.shade600,
|
appColors.warning,
|
||||||
Colors.orange.shade400,
|
appColors.warning.withValues(alpha: 0.85),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: Colors.orange.withValues(alpha: 0.3),
|
color: appColors.warning.withValues(alpha: 0.3),
|
||||||
blurRadius: 8,
|
blurRadius: 8,
|
||||||
offset: const Offset(0, 2),
|
offset: const Offset(0, 2),
|
||||||
),
|
),
|
||||||
|
|
@ -98,7 +101,7 @@ class PendingAnalysisBanner extends ConsumerWidget {
|
||||||
child: Text(
|
child: Text(
|
||||||
'$pendingCount件',
|
'$pendingCount件',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.orange.shade700,
|
color: appColors.warning,
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -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.42+49
|
version: 1.0.43+50
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.10.1
|
sdk: ^3.10.1
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,21 @@
|
||||||
{
|
{
|
||||||
"date": "2026-04-16",
|
"version": "v1.0.43",
|
||||||
"name": "Ponshu Room 1.0.42 (2026-04-16)",
|
"name": "Ponshu Room 1.0.43 (2026-04-18)",
|
||||||
"version": "v1.0.42",
|
"date": "2026-04-18",
|
||||||
"apks": {
|
"apks": {
|
||||||
"eiji": {
|
"maita": {
|
||||||
"lite": {
|
"lite": {
|
||||||
"url": "https://posimai-lab.tail72e846.ts.net/mai/ponshu-room-lite/releases/download/v1.0.42/ponshu_room_consumer_eiji.apk",
|
"filename": "ponshu_room_consumer_maita.apk",
|
||||||
"size_mb": 89.2,
|
"url": "https://posimai-lab.tail72e846.ts.net/mai/ponshu-room-lite/releases/download/v1.0.43/ponshu_room_consumer_maita.apk",
|
||||||
"filename": "ponshu_room_consumer_eiji.apk"
|
"size_mb": 89
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"maita": {
|
"eiji": {
|
||||||
"lite": {
|
"lite": {
|
||||||
"url": "https://posimai-lab.tail72e846.ts.net/mai/ponshu-room-lite/releases/download/v1.0.42/ponshu_room_consumer_maita.apk",
|
"filename": "ponshu_room_consumer_eiji.apk",
|
||||||
"size_mb": 89.2,
|
"url": "https://posimai-lab.tail72e846.ts.net/mai/ponshu-room-lite/releases/download/v1.0.43/ponshu_room_consumer_eiji.apk",
|
||||||
"filename": "ponshu_room_consumer_maita.apk"
|
"size_mb": 89
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Loading…
Reference in New Issue