Compare commits
32 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
073e55cc51 | |
|
|
856e349848 | |
|
|
778d2a725a | |
|
|
bcba78a533 | |
|
|
a5a5f729fe | |
|
|
582553ccfa | |
|
|
902128a3ff | |
|
|
797dd67000 | |
|
|
191274c65a | |
|
|
ab18b544c2 | |
|
|
1bf59e02cc | |
|
|
0fb4f6ea8b | |
|
|
d72587ac19 | |
|
|
1a84163654 | |
|
|
2e770ff98d | |
|
|
e7bb4e494c | |
|
|
5bcacfffa3 | |
|
|
9fba57621a | |
|
|
dd9b814174 | |
|
|
5d8689b7ee | |
|
|
a62bcd1d11 | |
|
|
8ebd233305 | |
|
|
05c27d9cdf | |
|
|
cc5175ebae | |
|
|
b7f5edf9a9 | |
|
|
ac2a54d07a | |
|
|
d39db78c80 | |
|
|
4e6ff6d6e9 | |
|
|
68723a884e | |
|
|
fad896e817 | |
|
|
26183e458e | |
|
|
cad2855b6e |
|
|
@ -28,5 +28,5 @@ GITEA_REPO=ponshu_room_lite
|
|||
# VERCEL_PROJECT_ID=prj_xxxxxxxxxx
|
||||
|
||||
# APKビルド設定
|
||||
MAITA_API_KEY=AIzaSyDjPZGOHy-xAstpLks081SIbUdTyb_iJpU
|
||||
EIJI_API_KEY=AIzaSyBEwmTa9_2aiRrwr1mXE7Qriw8mIg1xr0U
|
||||
MAITA_API_KEY=AIzaSy_YOUR_GEMINI_KEY_HERE
|
||||
EIJI_API_KEY=AIzaSy_YOUR_GEMINI_KEY_HERE
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ jobs:
|
|||
- name: Set up Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
flutter-version: '3.29.x'
|
||||
flutter-version: '3.38.x'
|
||||
channel: 'stable'
|
||||
|
||||
- name: Install dependencies
|
||||
|
|
|
|||
|
|
@ -0,0 +1,56 @@
|
|||
# Ponshu Room Lite — AI 規約
|
||||
|
||||
## デプロイ手順(必ず守ること)
|
||||
|
||||
### Android APK ビルド
|
||||
```bash
|
||||
bash build_consumer.sh # maita + eiji の consumer APK を生成
|
||||
bash build_4_apks.sh # 全4バリアント(consumer + business × maita/eiji)
|
||||
```
|
||||
- `.env` から `MAITA_API_KEY` / `EIJI_API_KEY` を読んで `--dart-define` に渡す
|
||||
- 直接 `flutter build apk` を叩かないこと(キーが secrets.local.dart にフォールバックする)
|
||||
|
||||
### ダウンロードページ(Vercel)
|
||||
```bash
|
||||
# 必ず web/download/ ディレクトリから実行すること(web/ からではない)
|
||||
cd web/download && vercel --prod
|
||||
vercel alias set <deployment-url> ponshu-room-download.vercel.app
|
||||
```
|
||||
- **URL**: https://ponshu-room-download.vercel.app
|
||||
- `releases.json` を更新してからデプロイする
|
||||
- `web/` ルートは Flutter web アプリなので絶対にデプロイしない
|
||||
|
||||
### Gitea リリース(APK アップロード)
|
||||
```bash
|
||||
# APK ビルド後に Gitea の API でリリース作成 → アセットアップロード
|
||||
# 認証: git credential store(provider=generic)から自動取得
|
||||
GITEA_TOKEN=$(echo "protocol=http\nhost=100.76.7.3:3000" | git credential fill | grep password | cut -d= -f2)
|
||||
```
|
||||
|
||||
### iOS / TestFlight
|
||||
- GitHub tag push(`v*`)で自動トリガー(.github/workflows/ios_build.yml)
|
||||
- Flutter バージョンは **3.38.x** を使用(pubspec の sdk: ^3.10.1 に対応)
|
||||
|
||||
## リリース手順チェックリスト
|
||||
|
||||
1. `pubspec.yaml` のバージョン番号を上げる
|
||||
2. `git tag vX.Y.Z && git push gitea main`(タグも push)
|
||||
3. `bash build_consumer.sh` で APK ビルド
|
||||
4. Gitea API でリリース作成 + APK アップロード
|
||||
5. `web/download/releases.json` を新バージョンに更新
|
||||
6. `cd web/download && vercel --prod` → alias set
|
||||
7. iOS CI は GitHub tag push で自動実行
|
||||
|
||||
## ディレクトリ構成の注意点
|
||||
|
||||
| ディレクトリ | 内容 | デプロイ先 |
|
||||
|-------------|------|-----------|
|
||||
| `lib/` | Flutter アプリ本体 | APK / TestFlight |
|
||||
| `web/download/` | ダウンロードページ | ponshu-room-download.vercel.app |
|
||||
| `web/` ルート | Flutter web ビルド出力 | **デプロイ対象外** |
|
||||
|
||||
## secrets の扱い
|
||||
|
||||
- `lib/secrets.local.dart` — gitignore 済み。ローカル開発専用
|
||||
- リリースビルドは必ず `build_consumer.sh` 経由(`--dart-define` でキーを注入)
|
||||
- 直接 `flutter build apk --release` すると secrets.local.dart がバイナリに入る
|
||||
|
|
@ -9,6 +9,8 @@ import 'providers/theme_provider.dart';
|
|||
import 'screens/main_screen.dart';
|
||||
import 'screens/license_screen.dart';
|
||||
import 'services/migration_service.dart';
|
||||
import 'services/license_service.dart';
|
||||
import 'providers/license_provider.dart';
|
||||
|
||||
/// 店舗向けビルドかどうかを判定するビルド時フラグ
|
||||
///
|
||||
|
|
@ -74,9 +76,16 @@ void main() async {
|
|||
}
|
||||
}
|
||||
|
||||
// ちらつき防止: runApp の前にキャッシュ済みライセンス状態を取得し、
|
||||
// licenseStatusProvider が loading を経由せず即 AsyncData になるよう override する
|
||||
final cachedLicenseStatus = await LicenseService.getCachedStatusOnly();
|
||||
|
||||
runApp(
|
||||
const ProviderScope(
|
||||
child: MyApp(),
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
licenseInitialStatusProvider.overrideWithValue(cachedLicenseStatus),
|
||||
],
|
||||
child: const MyApp(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -157,12 +157,20 @@ class SakeItem extends HiveObject {
|
|||
);
|
||||
}
|
||||
|
||||
// Allow setting for UI updates (呼び出し元で必ず await sakeItem.save() すること)
|
||||
/// displayData/hiddenSpecs をまとめて更新して即座にHiveへ保存する。
|
||||
/// setterを直接使わずこのメソッドを使うこと。
|
||||
Future<void> applyUpdates({
|
||||
DisplayData? displayData,
|
||||
HiddenSpecs? hiddenSpecs,
|
||||
}) async {
|
||||
if (displayData != null) _displayData = displayData;
|
||||
if (hiddenSpecs != null) _hiddenSpecs = hiddenSpecs;
|
||||
await save();
|
||||
}
|
||||
|
||||
@Deprecated('Use applyUpdates() instead to ensure save() is always called.')
|
||||
set displayData(DisplayData val) {
|
||||
_displayData = val;
|
||||
// save() はここで呼ばない。setter は同期のため await できず、
|
||||
// unawaited save() はデータ消失リスクがある。
|
||||
// 呼び出し元(sakenowa_auto_matching_service.dart)で明示的に await save() する。
|
||||
}
|
||||
|
||||
HiddenSpecs get hiddenSpecs {
|
||||
|
|
@ -176,7 +184,7 @@ class SakeItem extends HiveObject {
|
|||
);
|
||||
}
|
||||
|
||||
// Allow setting for さけのわ auto-matching
|
||||
@Deprecated('Use applyUpdates() instead to ensure save() is always called.')
|
||||
set hiddenSpecs(HiddenSpecs val) {
|
||||
_hiddenSpecs = val;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,52 @@
|
|||
import 'dart:async';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../services/license_service.dart';
|
||||
|
||||
/// ライセンス状態の非同期プロバイダー
|
||||
/// 起動時にキャッシュから事前ロードした値を保持するプロバイダー
|
||||
///
|
||||
/// アプリ起動時に一度だけVPSに問い合わせ、結果をキャッシュする。
|
||||
/// 手動更新は [licenseStatusProvider].invalidate() を呼ぶ。
|
||||
final licenseStatusProvider = FutureProvider<LicenseStatus>((ref) async {
|
||||
/// main() で override して初期値を渡すことで、
|
||||
/// licenseStatusProvider が loading 状態を経由せず即座に AsyncData になる。
|
||||
final licenseInitialStatusProvider = Provider<LicenseStatus?>((ref) => null);
|
||||
|
||||
/// ライセンス状態プロバイダー
|
||||
///
|
||||
/// - build() は licenseInitialStatusProvider に値がある場合は同期で返す(ちらつきなし)
|
||||
/// - 同時にバックグラウンドでサーバー検証を実行し、差異があれば状態を更新する
|
||||
final licenseStatusProvider =
|
||||
AsyncNotifierProvider<LicenseStatusNotifier, LicenseStatus>(
|
||||
LicenseStatusNotifier.new,
|
||||
);
|
||||
|
||||
class LicenseStatusNotifier extends AsyncNotifier<LicenseStatus> {
|
||||
@override
|
||||
FutureOr<LicenseStatus> build() {
|
||||
final initial = ref.read(licenseInitialStatusProvider);
|
||||
if (initial != null) {
|
||||
// キャッシュ値を同期で返すことで loading 状態をスキップ
|
||||
_refreshFromServer();
|
||||
return initial;
|
||||
}
|
||||
// override なし(テスト等): 通常フロー
|
||||
return LicenseService.checkStatus();
|
||||
});
|
||||
}
|
||||
|
||||
/// バックグラウンドでサーバー検証を実行し、変化があれば状態を更新する
|
||||
Future<void> _refreshFromServer() async {
|
||||
try {
|
||||
final fresh = await LicenseService.checkStatus();
|
||||
if (state.hasValue && state.value != fresh) {
|
||||
state = AsyncData(fresh);
|
||||
}
|
||||
} catch (_) {
|
||||
// ネットワークエラーはキャッシュ値を維持
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Pro版かどうか(ナビゲーション・機能解放の分岐に使う)
|
||||
///
|
||||
/// licenseStatusProvider が同期で AsyncData を返すため、
|
||||
/// アプリ起動時に false をちらつかせることなく正しい値を返す。
|
||||
final isProProvider = Provider<bool>((ref) {
|
||||
final statusAsync = ref.watch(licenseStatusProvider);
|
||||
return statusAsync.maybeWhen(
|
||||
|
|
|
|||
|
|
@ -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,8 +6,10 @@ import 'package:lucide_icons/lucide_icons.dart';
|
|||
|
||||
import '../models/sake_item.dart';
|
||||
import '../providers/gemini_provider.dart';
|
||||
import '../providers/quota_lockout_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';
|
||||
|
|
@ -23,7 +25,6 @@ import '../widgets/analyzing_dialog.dart';
|
|||
/// - _performSakenowaMatching() : バックグラウンドさけのわ自動マッチング
|
||||
mixin CameraAnalysisMixin<T extends ConsumerStatefulWidget> on ConsumerState<T> {
|
||||
final List<String> capturedImages = [];
|
||||
DateTime? quotaLockoutTime;
|
||||
|
||||
Future<void> analyzeImages() async {
|
||||
if (capturedImages.isEmpty) return;
|
||||
|
|
@ -31,6 +32,7 @@ mixin CameraAnalysisMixin<T extends ConsumerStatefulWidget> on ConsumerState<T>
|
|||
// async gap 前に context 依存オブジェクトをキャプチャ
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final navigator = Navigator.of(context);
|
||||
final appColors = Theme.of(context).extension<AppColors>()!;
|
||||
|
||||
final isOnline = await NetworkService.isOnline();
|
||||
if (!isOnline) {
|
||||
|
|
@ -38,30 +40,30 @@ mixin CameraAnalysisMixin<T extends ConsumerStatefulWidget> on ConsumerState<T>
|
|||
debugPrint('Offline detected: Saving as draft...');
|
||||
|
||||
try {
|
||||
await DraftService.saveDraft(capturedImages);
|
||||
await DraftService.saveDraft(capturedImages, reason: DraftReason.offline);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
messenger.showSnackBar(
|
||||
const SnackBar(
|
||||
SnackBar(
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(LucideIcons.wifiOff, color: Colors.orange, size: 16),
|
||||
SizedBox(width: 8),
|
||||
Text('オフライン検知', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
Icon(LucideIcons.wifiOff, color: Colors.white, size: 16),
|
||||
const SizedBox(width: 8),
|
||||
const Text('オフライン検知', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
],
|
||||
),
|
||||
SizedBox(height: 4),
|
||||
Text('写真を「解析待ち」として保存しました。'),
|
||||
Text('オンライン復帰後、ホーム画面から解析できます。'),
|
||||
const SizedBox(height: 4),
|
||||
const Text('写真を「解析待ち」として保存しました。'),
|
||||
const Text('オンライン復帰後、ホーム画面から解析できます。'),
|
||||
],
|
||||
),
|
||||
duration: Duration(seconds: 5),
|
||||
backgroundColor: Colors.orange,
|
||||
duration: const Duration(seconds: 5),
|
||||
backgroundColor: appColors.warning,
|
||||
),
|
||||
);
|
||||
|
||||
|
|
@ -71,26 +73,78 @@ mixin CameraAnalysisMixin<T extends ConsumerStatefulWidget> on ConsumerState<T>
|
|||
debugPrint('Draft save error: $e');
|
||||
if (!mounted) return;
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text('Draft保存エラー: $e')),
|
||||
const SnackBar(content: Text('写真の一時保存に失敗しました。再度お試しください。')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// クォータ事前チェック(日次上限 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: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(LucideIcons.zap, color: Colors.white, size: 16),
|
||||
const SizedBox(width: 8),
|
||||
const Text('本日のAI解析上限(20回)に達しました',
|
||||
style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
const Text('写真を「解析待ち」として保存しました。'),
|
||||
Text('$resetStr 以降にホーム画面から解析できます。'),
|
||||
],
|
||||
),
|
||||
duration: const Duration(seconds: 6),
|
||||
backgroundColor: appColors.warning,
|
||||
),
|
||||
);
|
||||
navigator.pop(); // カメラ画面を閉じてホームへ
|
||||
} catch (e) {
|
||||
debugPrint('Draft save error (quota): $e');
|
||||
if (!mounted) return;
|
||||
messenger.showSnackBar(
|
||||
const SnackBar(content: Text('写真の一時保存に失敗しました。再度お試しください。')),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// オンライン時: 通常の解析フロー
|
||||
if (!mounted) return;
|
||||
|
||||
final stageNotifier = ValueNotifier<int>(1);
|
||||
var stageNotifierDisposed = false;
|
||||
|
||||
// ignore: use_build_context_synchronously
|
||||
// 直前の mounted チェックにより BuildContext の有効性は保証されている
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) => const AnalyzingDialog(),
|
||||
builder: (context) => AnalyzingDialog(stageNotifier: stageNotifier),
|
||||
);
|
||||
|
||||
try {
|
||||
debugPrint('Starting Gemini Vision Direct Analysis for ${capturedImages.length} images');
|
||||
debugPrint('Starting Gemini 2-stage analysis for ${capturedImages.length} images');
|
||||
final geminiService = ref.read(geminiServiceProvider);
|
||||
final result = await geminiService.analyzeSakeLabel(capturedImages);
|
||||
final result = await geminiService.analyzeSakeLabel(
|
||||
capturedImages,
|
||||
onStep1Complete: () {
|
||||
if (!stageNotifierDisposed) stageNotifier.value = 2;
|
||||
},
|
||||
);
|
||||
|
||||
// Create SakeItem (Schema v2.0)
|
||||
final sakeItem = SakeItem(
|
||||
|
|
@ -125,6 +179,12 @@ mixin CameraAnalysisMixin<T extends ConsumerStatefulWidget> on ConsumerState<T>
|
|||
final box = Hive.box<SakeItem>('sake_items');
|
||||
await box.add(sakeItem);
|
||||
|
||||
// API 使用回数をカウントアップ(キャッシュヒット時は実際の API 呼び出しなし)
|
||||
if (!result.isFromCache) {
|
||||
await ApiUsageService.increment();
|
||||
ref.invalidate(apiUsageCountProvider);
|
||||
}
|
||||
|
||||
// さけのわ自動マッチング(非同期・バックグラウンド)
|
||||
// エラーが発生しても登録フローを中断しない
|
||||
_performSakenowaMatching(sakeItem).catchError((error) {
|
||||
|
|
@ -133,8 +193,10 @@ mixin CameraAnalysisMixin<T extends ConsumerStatefulWidget> on ConsumerState<T>
|
|||
|
||||
// Prepend new item to sort order so it appears at the top
|
||||
final settingsBox = Hive.box('settings');
|
||||
final List<String> currentOrder = (settingsBox.get('sake_sort_order') as List<dynamic>?)
|
||||
?.cast<String>() ?? [];
|
||||
final rawOrder = settingsBox.get('sake_sort_order');
|
||||
final List<String> currentOrder = (rawOrder is List)
|
||||
? rawOrder.whereType<String>().toList()
|
||||
: [];
|
||||
currentOrder.insert(0, sakeItem.id);
|
||||
await settingsBox.put('sake_sort_order', currentOrder);
|
||||
|
||||
|
|
@ -174,30 +236,27 @@ mixin CameraAnalysisMixin<T extends ConsumerStatefulWidget> on ConsumerState<T>
|
|||
navigator.pop(); // Close Camera Screen (Return to Home)
|
||||
|
||||
// Success Message
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
final List<Widget> messageWidgets = [
|
||||
Text('${sakeItem.displayData.displayName} を登録しました!'),
|
||||
];
|
||||
|
||||
if (result.isFromCache) {
|
||||
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 {
|
||||
messageWidgets.add(const SizedBox(height: 4));
|
||||
messageWidgets.add(Row(
|
||||
children: [
|
||||
Icon(LucideIcons.sparkles,
|
||||
color: isDark ? Colors.yellow.shade300 : Colors.yellow,
|
||||
size: 16),
|
||||
Icon(LucideIcons.sparkles, color: appColors.brandAccent, size: 16),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'経験値 +$expGained GET!${isLevelUp ? " (Level UP!)" : ""}',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: isDark ? Colors.yellow.shade200 : Colors.yellowAccent,
|
||||
color: appColors.brandAccent,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
@ -216,7 +275,7 @@ mixin CameraAnalysisMixin<T extends ConsumerStatefulWidget> on ConsumerState<T>
|
|||
'バッジ獲得: ${badge.name}',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: isDark ? Colors.green.shade300 : Colors.greenAccent,
|
||||
color: appColors.success,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
@ -243,29 +302,29 @@ mixin CameraAnalysisMixin<T extends ConsumerStatefulWidget> on ConsumerState<T>
|
|||
// AIサーバー混雑(503)→ ドラフト保存してオフライン時と同じ扱いに
|
||||
if (e is GeminiCongestionException) {
|
||||
try {
|
||||
await DraftService.saveDraft(capturedImages);
|
||||
await DraftService.saveDraft(capturedImages, reason: DraftReason.congestion);
|
||||
if (!mounted) return;
|
||||
navigator.pop(); // Close camera screen
|
||||
messenger.showSnackBar(
|
||||
const SnackBar(
|
||||
SnackBar(
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
const Row(
|
||||
children: [
|
||||
Icon(LucideIcons.cloudOff, color: Colors.white, size: 16),
|
||||
SizedBox(width: 8),
|
||||
Text('AIサーバー混雑', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
],
|
||||
),
|
||||
SizedBox(height: 4),
|
||||
Text('写真を「解析待ち」として保存しました。'),
|
||||
Text('時間をおいてホーム画面から解析できます。'),
|
||||
const SizedBox(height: 4),
|
||||
const Text('写真を「解析待ち」として保存しました。'),
|
||||
const Text('時間をおいてホーム画面から解析できます。'),
|
||||
],
|
||||
),
|
||||
duration: Duration(seconds: 5),
|
||||
backgroundColor: Colors.orange,
|
||||
duration: const Duration(seconds: 5),
|
||||
backgroundColor: appColors.warning,
|
||||
),
|
||||
);
|
||||
} catch (draftError) {
|
||||
|
|
@ -278,26 +337,82 @@ mixin CameraAnalysisMixin<T extends ConsumerStatefulWidget> on ConsumerState<T>
|
|||
return;
|
||||
}
|
||||
|
||||
// Quota エラー(429)→ ロックアウト
|
||||
// Quota エラー(429)→ ドラフト保存してカメラを閉じる
|
||||
final errStr = e.toString();
|
||||
if (errStr.contains('Quota') || errStr.contains('429')) {
|
||||
setState(() {
|
||||
quotaLockoutTime = DateTime.now().add(const Duration(minutes: 1));
|
||||
});
|
||||
}
|
||||
|
||||
final appColors = Theme.of(context).extension<AppColors>()!;
|
||||
try {
|
||||
await DraftService.saveDraft(capturedImages, reason: DraftReason.quotaLimit);
|
||||
ref.read(quotaLockoutProvider.notifier).set(DateTime.now().add(const Duration(minutes: 1)));
|
||||
if (!mounted) return;
|
||||
navigator.pop(); // カメラ画面を閉じる
|
||||
final resetTime = ApiUsageService.getNextResetTime();
|
||||
final resetStr = '${resetTime.hour}:${resetTime.minute.toString().padLeft(2, '0')}';
|
||||
messenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('解析エラー: $e'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Row(
|
||||
children: [
|
||||
Icon(LucideIcons.zap, color: Colors.white, 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: appColors.warning,
|
||||
),
|
||||
);
|
||||
} catch (draftError) {
|
||||
debugPrint('Draft save failed after 429: $draftError');
|
||||
if (!mounted) return;
|
||||
messenger.showSnackBar(
|
||||
const SnackBar(content: Text('解析もドラフト保存も失敗しました。再試行してください。')),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
debugPrint('Analysis error: $e');
|
||||
final errDetail = _extractErrorCode(e.toString());
|
||||
messenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('解析に失敗しました。時間をおいて再試行してください。$errDetail'),
|
||||
duration: const Duration(seconds: 5),
|
||||
backgroundColor: appColors.error,
|
||||
),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
stageNotifierDisposed = true;
|
||||
stageNotifier.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// エラー文字列から HTTP ステータスコードや既知キーワードを抽出して表示用の短い補足を返す。
|
||||
/// ユーザーが画面を見たときに「どのエラーか」を把握できるようにするため。
|
||||
String _extractErrorCode(String err) {
|
||||
final patterns = {
|
||||
RegExp(r'\b(4\d{2}|5\d{2})\b'): (Match m) => ' [${m.group(0)}]',
|
||||
RegExp(r'API_KEY_INVALID|PERMISSION_DENIED'): (_) => ' [key?]',
|
||||
RegExp(r'RESOURCE_EXHAUSTED'): (_) => ' [quota]',
|
||||
RegExp(r'NOT_FOUND'): (_) => ' [model?]',
|
||||
RegExp(r'timeout', caseSensitive: false): (_) => ' [timeout]',
|
||||
};
|
||||
for (final entry in patterns.entries) {
|
||||
final m = entry.key.firstMatch(err);
|
||||
if (m != null) return entry.value(m);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/// さけのわ自動マッチング処理
|
||||
///
|
||||
/// 登録後にバックグラウンドで実行。
|
||||
|
|
|
|||
|
|
@ -0,0 +1,70 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
/// カメラ画面右端の露出スライダーを描画する CustomPainter。
|
||||
///
|
||||
/// - 縦トラック(白半透明)
|
||||
/// - 中央基準線(0 EV を示すマーカー)
|
||||
/// - 現在値を示すノブ(影付き白丸)
|
||||
class ExposureSliderPainter extends CustomPainter {
|
||||
final double currentValue;
|
||||
final double minValue;
|
||||
final double maxValue;
|
||||
|
||||
const ExposureSliderPainter({
|
||||
required this.currentValue,
|
||||
required this.minValue,
|
||||
required this.maxValue,
|
||||
});
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final trackPaint = Paint()
|
||||
..color = Colors.white.withValues(alpha: 0.3)
|
||||
..strokeWidth = 4
|
||||
..strokeCap = StrokeCap.round;
|
||||
|
||||
final centerLinePaint = Paint()
|
||||
..color = Colors.white54
|
||||
..strokeWidth = 2;
|
||||
|
||||
final knobPaint = Paint()
|
||||
..color = Colors.white
|
||||
..style = PaintingStyle.fill;
|
||||
|
||||
final knobShadowPaint = Paint()
|
||||
..color = Colors.black26
|
||||
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 4);
|
||||
|
||||
// 縦トラック(中央線)
|
||||
final trackX = size.width / 2;
|
||||
canvas.drawLine(
|
||||
Offset(trackX, 10),
|
||||
Offset(trackX, size.height - 10),
|
||||
trackPaint,
|
||||
);
|
||||
|
||||
// 0 EV マーカー
|
||||
canvas.drawLine(
|
||||
Offset(trackX - 6, size.height / 2),
|
||||
Offset(trackX + 6, size.height / 2),
|
||||
centerLinePaint,
|
||||
);
|
||||
|
||||
// ノブ位置を算出
|
||||
final range = maxValue - minValue;
|
||||
if (range > 0) {
|
||||
// minValue(下端) → 0.0、maxValue(上端) → 1.0 に正規化してY座標に変換
|
||||
final normalized = (currentValue - minValue) / range;
|
||||
final knobY = (size.height - 20) * (1.0 - normalized) + 10;
|
||||
canvas.drawCircle(Offset(trackX, knobY), 7, knobShadowPaint);
|
||||
canvas.drawCircle(Offset(trackX, knobY), 6, knobPaint);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(ExposureSliderPainter oldDelegate) {
|
||||
return oldDelegate.currentValue != currentValue ||
|
||||
oldDelegate.minValue != minValue ||
|
||||
oldDelegate.maxValue != maxValue;
|
||||
}
|
||||
}
|
||||
|
|
@ -10,9 +10,11 @@ import 'package:gal/gal.dart';
|
|||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:image_picker/image_picker.dart'; // Gallery & Camera
|
||||
|
||||
import '../providers/quota_lockout_provider.dart';
|
||||
import '../services/image_compression_service.dart';
|
||||
import '../theme/app_colors.dart';
|
||||
import 'camera_analysis_mixin.dart';
|
||||
import 'camera_exposure_painter.dart';
|
||||
|
||||
|
||||
enum CameraMode {
|
||||
|
|
@ -164,10 +166,11 @@ class _CameraScreenState extends ConsumerState<CameraScreen> with SingleTickerPr
|
|||
|
||||
Future<void> _takePicture() async {
|
||||
// Check Quota Lockout
|
||||
if (quotaLockoutTime != null) {
|
||||
final remaining = quotaLockoutTime!.difference(DateTime.now());
|
||||
final quotaLockout = ref.read(quotaLockoutProvider);
|
||||
if (quotaLockout != null) {
|
||||
final remaining = quotaLockout.difference(DateTime.now());
|
||||
if (remaining.isNegative) {
|
||||
setState(() => quotaLockoutTime = null); // Reset
|
||||
ref.read(quotaLockoutProvider.notifier).set(null);
|
||||
} else {
|
||||
final appColors = Theme.of(context).extension<AppColors>()!;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
|
|
@ -222,7 +225,7 @@ class _CameraScreenState extends ConsumerState<CameraScreen> with SingleTickerPr
|
|||
final appColors = Theme.of(context).extension<AppColors>()!;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('ギャラリー保存に失敗しました: $e'),
|
||||
content: const Text('ギャラリーへの保存に失敗しました'),
|
||||
duration: const Duration(seconds: 4),
|
||||
backgroundColor: appColors.warning,
|
||||
),
|
||||
|
|
@ -300,6 +303,7 @@ class _CameraScreenState extends ConsumerState<CameraScreen> with SingleTickerPr
|
|||
|
||||
// Batch handle - Notification only
|
||||
if (mounted) {
|
||||
final appColors = Theme.of(context).extension<AppColors>()!;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('${images.length}枚の画像を読み込みました。\n右下のボタンから解析を開始してください。'),
|
||||
|
|
@ -307,7 +311,7 @@ class _CameraScreenState extends ConsumerState<CameraScreen> with SingleTickerPr
|
|||
action: SnackBarAction(
|
||||
label: '解析する',
|
||||
onPressed: analyzeImages,
|
||||
textColor: Colors.yellow,
|
||||
textColor: appColors.brandAccent,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
@ -366,6 +370,8 @@ class _CameraScreenState extends ConsumerState<CameraScreen> with SingleTickerPr
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final quotaLockout = ref.watch(quotaLockoutProvider);
|
||||
final appColors = Theme.of(context).extension<AppColors>()!;
|
||||
if (_cameraError != null) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
|
|
@ -475,8 +481,8 @@ class _CameraScreenState extends ConsumerState<CameraScreen> with SingleTickerPr
|
|||
height: 180,
|
||||
width: 48, // Wider for easier tapping
|
||||
child: CustomPaint(
|
||||
key: ValueKey(_currentExposureOffset), // Force repaint on value change
|
||||
painter: _ExposureSliderPainter(
|
||||
key: ValueKey(_currentExposureOffset),
|
||||
painter: ExposureSliderPainter(
|
||||
currentValue: _currentExposureOffset,
|
||||
minValue: _minExposure,
|
||||
maxValue: _maxExposure,
|
||||
|
|
@ -563,22 +569,22 @@ class _CameraScreenState extends ConsumerState<CameraScreen> with SingleTickerPr
|
|||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: quotaLockoutTime != null ? Colors.red : Colors.white,
|
||||
color: quotaLockout != null ? appColors.error : Colors.white,
|
||||
width: 4
|
||||
),
|
||||
color: _isTakingPicture
|
||||
? 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: quotaLockoutTime != null
|
||||
child: quotaLockout != null
|
||||
? const Icon(LucideIcons.timer, color: Colors.white, size: 30)
|
||||
: Container(
|
||||
height: 60,
|
||||
width: 60,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: quotaLockoutTime != null ? Colors.grey : Colors.white,
|
||||
color: quotaLockout != null ? appColors.textTertiary : Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
@ -590,7 +596,7 @@ class _CameraScreenState extends ConsumerState<CameraScreen> with SingleTickerPr
|
|||
IconButton(
|
||||
icon: Badge(
|
||||
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,
|
||||
tooltip: '解析を開始',
|
||||
|
|
@ -656,63 +662,3 @@ class _CameraScreenState extends ConsumerState<CameraScreen> with SingleTickerPr
|
|||
|
||||
}
|
||||
|
||||
// Custom Painter for Exposure Slider
|
||||
class _ExposureSliderPainter extends CustomPainter {
|
||||
final double currentValue;
|
||||
final double minValue;
|
||||
final double maxValue;
|
||||
|
||||
_ExposureSliderPainter({
|
||||
required this.currentValue,
|
||||
required this.minValue,
|
||||
required this.maxValue,
|
||||
});
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final trackPaint = Paint()
|
||||
..color = Colors.white.withValues(alpha: 0.3)
|
||||
..strokeWidth = 4
|
||||
..strokeCap = StrokeCap.round;
|
||||
|
||||
final centerLinePaint = Paint()
|
||||
..color = Colors.white54
|
||||
..strokeWidth = 2;
|
||||
|
||||
final knobPaint = Paint()
|
||||
..color = Colors.white
|
||||
..style = PaintingStyle.fill; final knobShadowPaint = Paint()
|
||||
..color = Colors.black26
|
||||
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 4); // Draw vertical track (centered)
|
||||
final trackX = size.width / 2;
|
||||
canvas.drawLine(
|
||||
Offset(trackX, 10),
|
||||
Offset(trackX, size.height - 10),
|
||||
trackPaint,
|
||||
); // Draw center marker
|
||||
canvas.drawLine(
|
||||
Offset(trackX - 6, size.height / 2),
|
||||
Offset(trackX + 6, size.height / 2),
|
||||
centerLinePaint,
|
||||
); // Calculate knob position
|
||||
final range = maxValue - minValue;
|
||||
if (range > 0) {
|
||||
// Normalize currentValue to 0.0-1.0 range
|
||||
// minValue (e.g., -4.0) -> 0.0 (bottom)
|
||||
// 0.0 (center) -> 0.5 (middle)
|
||||
// maxValue (e.g., +4.0) -> 1.0 (top)
|
||||
final normalized = (currentValue - minValue) / range;
|
||||
// Map to Y coordinate: 0.0 (normalized) -> bottom, 1.0 (normalized) -> top
|
||||
final knobY = (size.height - 20) * (1.0 - normalized) + 10;
|
||||
// Draw knob shadow
|
||||
canvas.drawCircle(Offset(trackX, knobY), 7, knobShadowPaint);
|
||||
// Draw knob
|
||||
canvas.drawCircle(Offset(trackX, knobY), 6, knobPaint);
|
||||
}
|
||||
} @override
|
||||
bool shouldRepaint(_ExposureSliderPainter oldDelegate) {
|
||||
return oldDelegate.currentValue != currentValue ||
|
||||
oldDelegate.minValue != minValue ||
|
||||
oldDelegate.maxValue != maxValue;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -58,8 +58,9 @@ class _SommelierScreenState extends ConsumerState<SommelierScreen> {
|
|||
);
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
debugPrint('Share error: $e');
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('シェアに失敗しました: $e')),
|
||||
const SnackBar(content: Text('シェアに失敗しました。再度お試しください。')),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
|
|
@ -541,7 +542,7 @@ class _SommelierScreenState extends ConsumerState<SommelierScreen> {
|
|||
debugPrint('Diagnosis Error: $e');
|
||||
if (!mounted) return;
|
||||
navigator.pop();
|
||||
messenger.showSnackBar(SnackBar(content: Text('エラー: $e')));
|
||||
messenger.showSnackBar(const SnackBar(content: Text('診断に失敗しました。時間をおいて再試行してください。')));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -184,11 +184,11 @@ class HomeScreen extends ConsumerWidget {
|
|||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(LucideIcons.clipboardCheck, size: 60, color: Colors.grey[400]),
|
||||
Icon(LucideIcons.clipboardCheck, size: 60, color: appColors.iconSubtle),
|
||||
const SizedBox(height: 16),
|
||||
Text(t['noMenuItems'], style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 8),
|
||||
Text(t['goBackToList'], textAlign: TextAlign.center, style: const TextStyle(color: Colors.grey)),
|
||||
Text(t['goBackToList'], textAlign: TextAlign.center, style: TextStyle(color: appColors.textSecondary)),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ class _MainScreenState extends ConsumerState<MainScreen> {
|
|||
clipBehavior: Clip.none,
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
Icon(featureIcon, size: 48, color: Colors.grey.shade400),
|
||||
Icon(featureIcon, size: 48, color: appColors.iconSubtle),
|
||||
Positioned(
|
||||
right: -8,
|
||||
top: -8,
|
||||
|
|
|
|||
|
|
@ -27,27 +27,26 @@ class _MenuPricingScreenState extends ConsumerState<MenuPricingScreen> {
|
|||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// One-time hint for Exit button
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
final prefs = SharedPreferences.getInstance();
|
||||
final shown = prefs.then((p) => p.getBool('business_mode_help_shown') ?? false);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _showExitHintIfNeeded());
|
||||
}
|
||||
|
||||
shown.then((hasShown) {
|
||||
Future<void> _showExitHintIfNeeded() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final hasShown = prefs.getBool('business_mode_help_shown') ?? false;
|
||||
if (!hasShown && mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: const Text('右上の×でいつでも終了できます'),
|
||||
duration: const Duration(seconds: 3),
|
||||
action: SnackBarAction(
|
||||
label: 'OK',
|
||||
onPressed: () {},
|
||||
),
|
||||
action: SnackBarAction(label: 'OK', onPressed: () {}),
|
||||
),
|
||||
);
|
||||
prefs.then((p) => p.setBool('business_mode_help_shown', true));
|
||||
await prefs.setBool('business_mode_help_shown', true);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Failed to load/save business_mode_help_shown: $e');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
|
|
@ -123,7 +122,7 @@ class _MenuPricingScreenState extends ConsumerState<MenuPricingScreen> {
|
|||
preferredSize: const Size.fromHeight(2),
|
||||
child: LinearProgressIndicator(
|
||||
value: 2 / 3, // Step 2 of 3 = 66%
|
||||
backgroundColor: Colors.grey[200],
|
||||
backgroundColor: Theme.of(context).extension<AppColors>()!.surfaceSubtle,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(Theme.of(context).primaryColor),
|
||||
minHeight: 2,
|
||||
),
|
||||
|
|
@ -320,9 +319,9 @@ class _MenuPricingScreenState extends ConsumerState<MenuPricingScreen> {
|
|||
// Drag Handle
|
||||
ReorderableDragStartListener(
|
||||
index: index,
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.only(right: 12, top: 4, bottom: 4),
|
||||
child: Icon(Icons.drag_indicator, color: Colors.grey),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(right: 12, top: 4, bottom: 4),
|
||||
child: Icon(Icons.drag_indicator, color: appColors.iconSubtle),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
|
|
|
|||
|
|
@ -95,7 +95,7 @@ class PdfPreviewScreen extends ConsumerWidget {
|
|||
],
|
||||
),
|
||||
loadingWidget: const Center(child: CircularProgressIndicator()),
|
||||
onError: (context, error) => Center(child: Text('エラーが発生しました: $error')),
|
||||
onError: (context, error) => const Center(child: Text('PDFの表示に失敗しました')),
|
||||
),
|
||||
bottomNavigationBar: SafeArea(
|
||||
child: Column(
|
||||
|
|
@ -246,9 +246,10 @@ class PdfPreviewScreen extends ConsumerWidget {
|
|||
filename: 'oshinagaki_${DateTime.now().toString().split(' ')[0]}.pdf',
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('PDF share error: $e');
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('共有エラー: $e')),
|
||||
const SnackBar(content: Text('PDFの共有に失敗しました。再度お試しください。')),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -284,7 +285,7 @@ class PdfPreviewScreen extends ConsumerWidget {
|
|||
..name = fileName
|
||||
..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(
|
||||
driveFile,
|
||||
uploadMedia: drive.Media(
|
||||
|
|
@ -294,11 +295,11 @@ class PdfPreviewScreen extends ConsumerWidget {
|
|||
);
|
||||
|
||||
if (uploadedFile.id == null) {
|
||||
debugPrint('[PDF_DRIVE] ❌ アップロード失敗: ID取得不可');
|
||||
debugPrint('[PDF_DRIVE] Upload failed: no file ID returned');
|
||||
throw Exception('アップロードに失敗しました(IDなし)');
|
||||
}
|
||||
|
||||
debugPrint('[PDF_DRIVE] ✅ アップロード完了: ID=${uploadedFile.id}');
|
||||
debugPrint('[PDF_DRIVE] Upload complete: ID=${uploadedFile.id}');
|
||||
|
||||
// 5. Success notification
|
||||
if (context.mounted) {
|
||||
|
|
@ -314,11 +315,12 @@ class PdfPreviewScreen extends ConsumerWidget {
|
|||
);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Drive upload error: $e');
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Driveアップロードエラー: $e'),
|
||||
duration: const Duration(seconds: 4),
|
||||
const SnackBar(
|
||||
content: Text('Google Driveへの保存に失敗しました。再度お試しください。'),
|
||||
duration: Duration(seconds: 4),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -336,9 +338,10 @@ class PdfPreviewScreen extends ConsumerWidget {
|
|||
format: _getPageFormat(pdfSize, ref.read(pdfIsPortraitProvider)),
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('Print error: $e');
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('印刷エラー: $e')),
|
||||
const SnackBar(content: Text('印刷に失敗しました。再度お試しください。')),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import '../models/sake_item.dart';
|
||||
import '../providers/gemini_provider.dart';
|
||||
import '../services/api_usage_service.dart';
|
||||
import '../services/draft_service.dart';
|
||||
import '../services/network_service.dart';
|
||||
import '../theme/app_colors.dart';
|
||||
|
|
@ -54,6 +55,24 @@ class _PendingAnalysisScreenState extends ConsumerState<PendingAnalysisScreen> {
|
|||
}
|
||||
}
|
||||
|
||||
/// ドラフト理由の説明文を返す
|
||||
String _getDraftNote(SakeItem draft) {
|
||||
final desc = draft.hiddenSpecs.description ?? '';
|
||||
switch (desc) {
|
||||
case 'quota':
|
||||
final resetTime = ApiUsageService.getNextResetTime();
|
||||
final resetStr = '${resetTime.hour}:${resetTime.minute.toString().padLeft(2, '0')}';
|
||||
return 'AI上限(20回/日)に達したため保留中。次のリセット $resetStr 以降に解析可';
|
||||
case 'congestion':
|
||||
return 'AIサーバー混雑のため保留中。「すべて解析」で再試行できます';
|
||||
default:
|
||||
return 'オフライン時に保存。オンライン接続後に解析できます';
|
||||
}
|
||||
}
|
||||
|
||||
bool _isQuotaDraft(SakeItem draft) =>
|
||||
draft.hiddenSpecs.description == 'quota';
|
||||
|
||||
Future<void> _analyzeAllDrafts() async {
|
||||
final appColors = Theme.of(context).extension<AppColors>()!;
|
||||
|
||||
|
|
@ -175,10 +194,11 @@ class _PendingAnalysisScreenState extends ConsumerState<PendingAnalysisScreen> {
|
|||
}
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
debugPrint('Draft delete error: $e');
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('削除エラー: $e'),
|
||||
duration: const Duration(seconds: 5),
|
||||
const SnackBar(
|
||||
content: Text('削除に失敗しました。再度お試しください。'),
|
||||
duration: Duration(seconds: 5),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -338,25 +358,49 @@ class _PendingAnalysisScreenState extends ConsumerState<PendingAnalysisScreen> {
|
|||
width: 60,
|
||||
height: 60,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, __, ___) => Container(
|
||||
width: 60,
|
||||
height: 60,
|
||||
decoration: BoxDecoration(
|
||||
color: appColors.divider,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(LucideIcons.image, color: appColors.iconSubtle),
|
||||
),
|
||||
),
|
||||
)
|
||||
: Container(
|
||||
width: 60,
|
||||
height: 60,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade300,
|
||||
color: appColors.divider,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Icon(LucideIcons.image, color: Colors.grey),
|
||||
child: Icon(LucideIcons.image, color: appColors.iconSubtle),
|
||||
),
|
||||
title: const Text(
|
||||
'解析待ち',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
subtitle: Text(
|
||||
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),
|
||||
onPressed: () => _deleteDraft(draft),
|
||||
|
|
@ -390,7 +434,7 @@ class _PendingAnalysisScreenState extends ConsumerState<PendingAnalysisScreen> {
|
|||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: appColors.brandPrimary,
|
||||
foregroundColor: Colors.white,
|
||||
disabledBackgroundColor: Colors.grey.shade400,
|
||||
disabledBackgroundColor: appColors.textTertiary,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -8,6 +8,10 @@ import '../../../constants/app_constants.dart';
|
|||
import '../../../providers/theme_provider.dart';
|
||||
import '../../../services/mbti_compatibility_service.dart';
|
||||
|
||||
String _formatRecordedDate(DateTime date) {
|
||||
return '${date.year}年${date.month}月${date.day}日に記録';
|
||||
}
|
||||
|
||||
/// 銘柄名・蔵元/都道府県・タグ・AI確信度バッジ・MBTI相性バッジ・キャッチコピーを表示するセクション
|
||||
class SakeBasicInfoSection extends ConsumerWidget {
|
||||
final SakeItem sake;
|
||||
|
|
@ -97,7 +101,7 @@ class SakeBasicInfoSection extends ConsumerWidget {
|
|||
decoration: BoxDecoration(
|
||||
color: badgeColor!.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: badgeColor!.withValues(alpha: 0.4)),
|
||||
border: Border.all(color: badgeColor.withValues(alpha: 0.4)),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
|
|
@ -105,7 +109,7 @@ class SakeBasicInfoSection extends ConsumerWidget {
|
|||
Icon(LucideIcons.brainCircuit, size: 12, color: badgeColor),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'$mbtiType ${mbtiResult!.starDisplay}',
|
||||
'$mbtiType ${mbtiResult.starDisplay}',
|
||||
style: TextStyle(
|
||||
color: badgeColor,
|
||||
fontWeight: FontWeight.bold,
|
||||
|
|
@ -161,6 +165,24 @@ class SakeBasicInfoSection extends ConsumerWidget {
|
|||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// 記録日
|
||||
if (sake.itemType != ItemType.set)
|
||||
Center(
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(LucideIcons.calendarDays, size: 11, color: appColors.textTertiary),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
_formatRecordedDate(sake.metadata.createdAt),
|
||||
style: TextStyle(fontSize: 11, color: appColors.textTertiary),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// キャッチコピー(位置を銘柄名直下に移動して存在感を強調)
|
||||
|
|
|
|||
|
|
@ -70,6 +70,10 @@ class SakeDetailSliverAppBar extends StatelessWidget {
|
|||
final imageWidget = Image.file(
|
||||
File(sake.displayData.imagePaths[index]),
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) => Container(
|
||||
color: Colors.grey[300],
|
||||
child: const Icon(Icons.broken_image, size: 60, color: Colors.grey),
|
||||
),
|
||||
);
|
||||
|
||||
// Apply Hero only to the first image for smooth transition from Grid/List
|
||||
|
|
@ -121,6 +125,10 @@ class SakeDetailSliverAppBar extends StatelessWidget {
|
|||
? Image.file(
|
||||
File(sake.displayData.imagePaths.first),
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) => Container(
|
||||
color: appColors.surfaceSubtle,
|
||||
child: Icon(LucideIcons.imageOff, size: 80, color: appColors.iconSubtle),
|
||||
),
|
||||
)
|
||||
: Container(
|
||||
color: appColors.surfaceSubtle,
|
||||
|
|
|
|||
|
|
@ -112,6 +112,12 @@ class _SakePhotoEditModalState extends State<SakePhotoEditModal> {
|
|||
width: 60,
|
||||
height: 60,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) => Container(
|
||||
width: 60,
|
||||
height: 60,
|
||||
color: Colors.grey[200],
|
||||
child: const Icon(Icons.broken_image, size: 24, color: Colors.grey),
|
||||
),
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
|
|
@ -234,9 +240,10 @@ class _SakePhotoEditModalState extends State<SakePhotoEditModal> {
|
|||
await _saveNewPhoto(savedPath);
|
||||
|
||||
} catch (e) {
|
||||
debugPrint('Photo pick error: $e');
|
||||
if (mounted) {
|
||||
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/sections/sake_mbti_stamp_section.dart';
|
||||
import '../providers/license_provider.dart';
|
||||
import '../providers/quota_lockout_provider.dart';
|
||||
import 'sake_detail/sections/sake_basic_info_section.dart';
|
||||
import 'sake_detail/widgets/sake_detail_sliver_app_bar.dart';
|
||||
import '../services/mbti_compatibility_service.dart';
|
||||
|
|
@ -36,10 +37,9 @@ class SakeDetailScreen extends ConsumerStatefulWidget {
|
|||
}
|
||||
|
||||
class _SakeDetailScreenState extends ConsumerState<SakeDetailScreen> {
|
||||
// To trigger rebuilds if we don't switch to a stream
|
||||
late SakeItem _sake;
|
||||
int _currentImageIndex = 0;
|
||||
// Memo logic moved to SakeDetailMemo
|
||||
bool _isAnalyzing = false;
|
||||
|
||||
|
||||
@override
|
||||
|
|
@ -119,14 +119,11 @@ class _SakeDetailScreenState extends ConsumerState<SakeDetailScreen> {
|
|||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: Theme.of(context).brightness == Brightness.dark
|
||||
? [
|
||||
const Color(0xFF121212), // Scaffold Background
|
||||
const Color(0xFF1E1E1E), // Slightly lighter surface
|
||||
]
|
||||
: [
|
||||
colors: [
|
||||
Theme.of(context).scaffoldBackgroundColor,
|
||||
Theme.of(context).primaryColor.withValues(alpha: 0.05),
|
||||
Theme.of(context).brightness == Brightness.dark
|
||||
? appColors.brandSurface
|
||||
: Theme.of(context).primaryColor.withValues(alpha: 0.05),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
@ -281,10 +278,6 @@ class _SakeDetailScreenState extends ConsumerState<SakeDetailScreen> {
|
|||
);
|
||||
}
|
||||
|
||||
bool _isAnalyzing = false;
|
||||
DateTime? _quotaLockoutTime;
|
||||
|
||||
|
||||
Future<void> _toggleFavorite() async {
|
||||
HapticFeedback.mediumImpact();
|
||||
final box = Hive.box<SakeItem>('sake_items');
|
||||
|
|
@ -309,13 +302,18 @@ class _SakeDetailScreenState extends ConsumerState<SakeDetailScreen> {
|
|||
// 1. Check Locks
|
||||
if (_isAnalyzing) return;
|
||||
|
||||
// async gap 前に context 依存オブジェクトをキャプチャ
|
||||
final nav = Navigator.of(context);
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
|
||||
// 2. Check Quota Lockout
|
||||
if (_quotaLockoutTime != null) {
|
||||
final remaining = _quotaLockoutTime!.difference(DateTime.now());
|
||||
final quotaLockout = ref.read(quotaLockoutProvider);
|
||||
if (quotaLockout != null) {
|
||||
final remaining = quotaLockout.difference(DateTime.now());
|
||||
if (remaining.isNegative) {
|
||||
setState(() => _quotaLockoutTime = null); // Reset if time passed
|
||||
ref.read(quotaLockoutProvider.notifier).set(null);
|
||||
} else {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text('AI利用制限中です。あと${remaining.inSeconds}秒お待ちください。')),
|
||||
);
|
||||
return;
|
||||
|
|
@ -332,8 +330,6 @@ class _SakeDetailScreenState extends ConsumerState<SakeDetailScreen> {
|
|||
}
|
||||
}
|
||||
if (!mounted) return;
|
||||
final nav = Navigator.of(context);
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
|
||||
if (existingPaths.isEmpty) {
|
||||
messenger.showSnackBar(
|
||||
|
|
@ -346,15 +342,16 @@ class _SakeDetailScreenState extends ConsumerState<SakeDetailScreen> {
|
|||
|
||||
try {
|
||||
// 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 result = await geminiService.analyzeSakeLabel(existingPaths, forceRefresh: true);
|
||||
// 再解析専用メソッド: 前回の name/brand を渡してモデルに再考させる
|
||||
// temperature=0.3 で非決定論的にすることで hallucination の繰り返しを防ぐ
|
||||
final result = await geminiService.reanalyzeSakeLabel(
|
||||
existingPaths,
|
||||
previousName: _sake.displayData.displayName,
|
||||
previousBrand: _sake.displayData.displayBrewery,
|
||||
);
|
||||
|
||||
final newItem = _sake.copyWith(
|
||||
name: result.name ?? _sake.displayData.displayName,
|
||||
|
|
@ -396,13 +393,12 @@ class _SakeDetailScreenState extends ConsumerState<SakeDetailScreen> {
|
|||
|
||||
// Check for Quota Error to set Lockout
|
||||
if (e.toString().contains('Quota') || e.toString().contains('429')) {
|
||||
setState(() {
|
||||
_quotaLockoutTime = DateTime.now().add(const Duration(minutes: 1));
|
||||
});
|
||||
ref.read(quotaLockoutProvider.notifier).set(DateTime.now().add(const Duration(minutes: 1)));
|
||||
}
|
||||
|
||||
debugPrint('Reanalyze error: $e');
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text('エラー: $e')),
|
||||
const SnackBar(content: Text('再解析に失敗しました。時間をおいて再試行してください。')),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
|
|
@ -413,11 +409,11 @@ class _SakeDetailScreenState extends ConsumerState<SakeDetailScreen> {
|
|||
}
|
||||
|
||||
|
||||
void _showTagEditDialog(BuildContext context) {
|
||||
Future<void> _showTagEditDialog(BuildContext context) async {
|
||||
final TextEditingController tagController = TextEditingController();
|
||||
final allTags = _sake.hiddenSpecs.flavorTags.toSet();
|
||||
|
||||
showDialog(
|
||||
try {
|
||||
await showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => StatefulBuilder(
|
||||
builder: (context, setModalState) {
|
||||
|
|
@ -498,6 +494,9 @@ class _SakeDetailScreenState extends ConsumerState<SakeDetailScreen> {
|
|||
}
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
tagController.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _updateTags(List<String> newTags) async {
|
||||
|
|
@ -581,6 +580,7 @@ class _SakeDetailScreenState extends ConsumerState<SakeDetailScreen> {
|
|||
required Future<void> Function(String) onSave,
|
||||
}) async {
|
||||
final controller = TextEditingController(text: initialValue);
|
||||
try {
|
||||
await showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
|
|
@ -608,6 +608,9 @@ class _SakeDetailScreenState extends ConsumerState<SakeDetailScreen> {
|
|||
],
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
controller.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// MBTI相性詳細ダイアログを表示
|
||||
|
|
@ -732,6 +735,7 @@ class _SakeDetailScreenState extends ConsumerState<SakeDetailScreen> {
|
|||
Future<void> _showBreweryEditDialog(BuildContext context) async {
|
||||
final breweryController = TextEditingController(text: _sake.displayData.displayBrewery);
|
||||
final prefectureController = TextEditingController(text: _sake.displayData.displayPrefecture);
|
||||
try {
|
||||
await showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
|
|
@ -778,6 +782,10 @@ class _SakeDetailScreenState extends ConsumerState<SakeDetailScreen> {
|
|||
],
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
breweryController.dispose();
|
||||
prefectureController.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// 写真編集モーダルを表示
|
||||
|
|
|
|||
|
|
@ -77,10 +77,10 @@ class _ScanARScreenState extends ConsumerState<ScanARScreen>
|
|||
_isInitializing = false;
|
||||
});
|
||||
}
|
||||
debugPrint('✅ Scanner: Controller created successfully');
|
||||
debugPrint('[Scanner] Controller created successfully');
|
||||
|
||||
} catch (e) {
|
||||
debugPrint('❌ Scanner: Error during initialization: $e');
|
||||
debugPrint('[Scanner] Error during initialization: $e');
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isInitializing = false;
|
||||
|
|
@ -395,7 +395,7 @@ class _DigitalSakeCardDialog extends StatelessWidget {
|
|||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'$brewery / $prefecture',
|
||||
style: const TextStyle(fontSize: 14, color: Colors.grey),
|
||||
style: TextStyle(fontSize: 14, color: appColors.textTertiary),
|
||||
),
|
||||
|
||||
const SizedBox(height: 20),
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import '../providers/theme_provider.dart';
|
||||
import '../theme/app_colors.dart';
|
||||
import '../widgets/settings/display_settings_section.dart';
|
||||
import '../widgets/settings/other_settings_section.dart';
|
||||
import '../widgets/settings/backup_settings_section.dart';
|
||||
|
|
@ -22,7 +23,7 @@ class _ShopSettingsScreenState extends ConsumerState<ShopSettingsScreen> {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final userProfile = ref.watch(userProfileProvider);
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
final appColors = Theme.of(context).extension<AppColors>()!;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
|
|
@ -35,9 +36,9 @@ class _ShopSettingsScreenState extends ConsumerState<ShopSettingsScreen> {
|
|||
// Business Config Section
|
||||
_buildSectionHeader(context, '価格設定', LucideIcons.briefcase),
|
||||
Card(
|
||||
color: isDark ? const Color(0xFF1E1E1E) : null,
|
||||
color: appColors.surfaceElevated,
|
||||
child: ListTile(
|
||||
leading: Icon(LucideIcons.percent, color: isDark ? Colors.orange[300] : Theme.of(context).primaryColor),
|
||||
leading: Icon(LucideIcons.percent, color: appColors.iconAccent),
|
||||
title: const Text('基本掛率'),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
|
|
@ -45,15 +46,15 @@ class _ShopSettingsScreenState extends ConsumerState<ShopSettingsScreen> {
|
|||
Text('×', style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
color: isDark ? Colors.grey[400] : Colors.grey[600],
|
||||
color: appColors.textSecondary,
|
||||
)),
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? Colors.grey[800] : Colors.grey[100],
|
||||
color: appColors.surfaceSubtle,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: isDark ? Colors.grey[700]! : Colors.grey[300]!),
|
||||
border: Border.all(color: appColors.divider),
|
||||
),
|
||||
child: DropdownButton<double>(
|
||||
value: userProfile.defaultMarkup,
|
||||
|
|
@ -96,18 +97,18 @@ class _ShopSettingsScreenState extends ConsumerState<ShopSettingsScreen> {
|
|||
}
|
||||
|
||||
Widget _buildSectionHeader(BuildContext context, String title, IconData icon) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
final appColors = Theme.of(context).extension<AppColors>()!;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon, size: 20, color: isDark ? Colors.orange[300] : Theme.of(context).primaryColor),
|
||||
Icon(icon, size: 20, color: appColors.iconAccent),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: isDark ? Colors.grey[300] : Theme.of(context).primaryColor,
|
||||
color: appColors.textPrimary,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
@ -11,6 +11,15 @@ import 'package:path_provider/path_provider.dart';
|
|||
import 'package:path/path.dart' as path;
|
||||
import '../models/sake_item.dart';
|
||||
|
||||
/// 復元前の安全バックアップ作成に失敗したことを示す例外。
|
||||
/// UI 側でユーザーに中断/続行を選ばせるために使用する。
|
||||
class PreRestoreBackupException implements Exception {
|
||||
const PreRestoreBackupException();
|
||||
|
||||
@override
|
||||
String toString() => 'PreRestoreBackupException: pre-restore safety backup failed';
|
||||
}
|
||||
|
||||
/// Google Driveへのバックアップ・復元を管理するサービス
|
||||
///
|
||||
/// 【主な機能】
|
||||
|
|
@ -325,8 +334,11 @@ class BackupService {
|
|||
|
||||
final driveApi = drive.DriveApi(authClient);
|
||||
|
||||
// 3. 現在のデータを退避
|
||||
await _createPreRestoreBackup();
|
||||
// 3. 現在のデータを退避(失敗したら呼び出し元に通知して中断させる)
|
||||
final preBackupOk = await _createPreRestoreBackup();
|
||||
if (!preBackupOk) {
|
||||
throw const PreRestoreBackupException();
|
||||
}
|
||||
|
||||
// 4. Google Driveからダウンロード
|
||||
final zipFile = await _downloadFromDrive(driveApi);
|
||||
|
|
@ -342,26 +354,55 @@ class BackupService {
|
|||
await zipFile.delete();
|
||||
|
||||
return success;
|
||||
} on PreRestoreBackupException {
|
||||
rethrow;
|
||||
} catch (error) {
|
||||
debugPrint('[RESTORE] Restore error: $error');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 復元前に現在のデータを退避
|
||||
Future<void> _createPreRestoreBackup() async {
|
||||
/// 復元前に現在のデータを退避する。成功したら true、失敗したら false を返す。
|
||||
Future<bool> _createPreRestoreBackup() async {
|
||||
try {
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final backupPath = path.join(tempDir.path, 'pre_restore_backup.zip');
|
||||
|
||||
final zipFile = await _createBackupZip();
|
||||
if (zipFile != null) {
|
||||
if (zipFile == null) {
|
||||
debugPrint('[RESTORE] Pre-restore backup: ZIP creation failed');
|
||||
return false;
|
||||
}
|
||||
await zipFile.copy(backupPath);
|
||||
await zipFile.delete();
|
||||
debugPrint('[RESTORE] Pre-restore backup saved: $backupPath');
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
debugPrint('[RESTORE] Pre-restore backup error: $error');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 安全バックアップをスキップして復元を強行する(ユーザーが警告を承諾した場合)。
|
||||
Future<bool> restoreBackupSkippingPreBackup() async {
|
||||
try {
|
||||
final account = _googleSignIn.currentUser;
|
||||
if (account == null) return false;
|
||||
|
||||
final authClient = await _googleSignIn.authenticatedClient();
|
||||
if (authClient == null) return false;
|
||||
|
||||
final driveApi = drive.DriveApi(authClient);
|
||||
|
||||
final zipFile = await _downloadFromDrive(driveApi);
|
||||
if (zipFile == null) return false;
|
||||
|
||||
final success = await _restoreFromZip(zipFile);
|
||||
await zipFile.delete();
|
||||
return success;
|
||||
} catch (error) {
|
||||
debugPrint('[RESTORE] Force restore error: $error');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -393,7 +434,14 @@ class BackupService {
|
|||
|
||||
// 3. ストリームをファイルに書き込み
|
||||
final sink = downloadFile.openWrite();
|
||||
await media.stream.pipe(sink);
|
||||
await media.stream.pipe(sink).timeout(
|
||||
const Duration(minutes: 3),
|
||||
onTimeout: () {
|
||||
sink.close();
|
||||
try { downloadFile.deleteSync(); } catch (_) {}
|
||||
throw TimeoutException('Backup download timed out after 3 minutes');
|
||||
},
|
||||
);
|
||||
|
||||
debugPrint('[RESTORE] Download complete: $downloadPath');
|
||||
return downloadFile;
|
||||
|
|
@ -455,7 +503,10 @@ class BackupService {
|
|||
final sakeBox = Hive.box<SakeItem>('sake_items');
|
||||
|
||||
await sakeBox.clear();
|
||||
int restoredCount = 0;
|
||||
int skippedCount = 0;
|
||||
for (var itemData in sakeItemsJson) {
|
||||
try {
|
||||
final data = itemData as Map<String, dynamic>;
|
||||
|
||||
// JSONからSakeItemオブジェクトを再構築
|
||||
|
|
@ -467,21 +518,21 @@ class BackupService {
|
|||
prefecture: data['displayData']['prefecture'] as String,
|
||||
catchCopy: data['displayData']['catchCopy'] as String?,
|
||||
imagePaths: List<String>.from(data['displayData']['imagePaths'] as List),
|
||||
rating: data['displayData']['rating'] as double?,
|
||||
rating: (data['displayData']['rating'] as num?)?.toDouble(),
|
||||
),
|
||||
hiddenSpecs: HiddenSpecs(
|
||||
description: data['hiddenSpecs']['description'] as String?,
|
||||
tasteStats: Map<String, int>.from(data['hiddenSpecs']['tasteStats'] as Map),
|
||||
flavorTags: List<String>.from(data['hiddenSpecs']['flavorTags'] as List),
|
||||
sweetnessScore: data['hiddenSpecs']['sweetnessScore'] as double?,
|
||||
bodyScore: data['hiddenSpecs']['bodyScore'] as double?,
|
||||
sweetnessScore: (data['hiddenSpecs']['sweetnessScore'] as num?)?.toDouble(),
|
||||
bodyScore: (data['hiddenSpecs']['bodyScore'] as num?)?.toDouble(),
|
||||
),
|
||||
userData: UserData(
|
||||
isFavorite: data['userData']['isFavorite'] as bool,
|
||||
isUserEdited: data['userData']['isUserEdited'] as bool,
|
||||
price: data['userData']['price'] as int?,
|
||||
costPrice: data['userData']['costPrice'] as int?,
|
||||
markup: data['userData']['markup'] as double,
|
||||
markup: (data['userData']['markup'] as num?)?.toDouble() ?? 3.0,
|
||||
priceVariants: data['userData']['priceVariants'] != null
|
||||
? Map<String, int>.from(data['userData']['priceVariants'] as Map)
|
||||
: null,
|
||||
|
|
@ -498,8 +549,13 @@ class BackupService {
|
|||
|
||||
// IDを保持するためにput()を使用(add()は新しいキーを生成してしまう)
|
||||
await sakeBox.put(item.id, item);
|
||||
restoredCount++;
|
||||
} catch (e) {
|
||||
skippedCount++;
|
||||
debugPrint('[RESTORE] Skipped malformed item: $e');
|
||||
}
|
||||
debugPrint('[RESTORE] SakeItems restored (${sakeItemsJson.length} items)');
|
||||
}
|
||||
debugPrint('[RESTORE] SakeItems restored ($restoredCount items, $skippedCount skipped)');
|
||||
// UI更新のためにわずかに待機
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,13 @@ import 'package:uuid/uuid.dart';
|
|||
import '../models/sake_item.dart';
|
||||
import 'gemini_service.dart';
|
||||
|
||||
/// 解析待ち(Draft)になった理由
|
||||
enum DraftReason {
|
||||
offline, // オフライン時に撮影
|
||||
quotaLimit, // AI 使用回数が上限(20回/日)に達した
|
||||
congestion, // AI サーバー混雑(503)
|
||||
}
|
||||
|
||||
/// Draft(解析待ちアイテム)管理サービス
|
||||
///
|
||||
/// オフライン時に撮影した写真を一時保存し、
|
||||
|
|
@ -29,13 +36,23 @@ class DraftService {
|
|||
/// ```dart
|
||||
/// final draftKey = await DraftService.saveDraft([imagePath1, imagePath2]);
|
||||
/// ```
|
||||
static Future<String> saveDraft(List<String> photoPaths) async {
|
||||
static Future<String> saveDraft(
|
||||
List<String> photoPaths, {
|
||||
DraftReason reason = DraftReason.offline,
|
||||
}) async {
|
||||
try {
|
||||
final box = Hive.box<SakeItem>('sake_items');
|
||||
|
||||
// FIX: 最初の画像をdraftPhotoPathに、全画像をimagePathsに保存
|
||||
final firstPhotoPath = photoPaths.isNotEmpty ? photoPaths.first : '';
|
||||
|
||||
// reason を description フィールドに格納(解析時に上書きされる)
|
||||
final reasonKey = switch (reason) {
|
||||
DraftReason.offline => 'offline',
|
||||
DraftReason.quotaLimit => 'quota',
|
||||
DraftReason.congestion => 'congestion',
|
||||
};
|
||||
|
||||
// Draft用の仮データを作成
|
||||
final draftItem = SakeItem(
|
||||
id: _uuid.v4(),
|
||||
|
|
@ -49,7 +66,7 @@ class DraftService {
|
|||
rating: null,
|
||||
),
|
||||
hiddenSpecs: HiddenSpecs(
|
||||
description: 'オフライン時に撮影された写真です。オンライン復帰後に自動解析されます。',
|
||||
description: reasonKey,
|
||||
tasteStats: {},
|
||||
flavorTags: [],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -13,30 +13,309 @@ class GeminiService {
|
|||
// AI Proxy Server Configuration
|
||||
static final String _proxyUrl = Secrets.aiProxyAnalyzeUrl;
|
||||
|
||||
// レート制限対策? Proxy側で管理されているが、念のためクライアント側でも連打防止
|
||||
static DateTime? _lastApiCallTime;
|
||||
static const Duration _minApiInterval = Duration(seconds: 2);
|
||||
|
||||
GeminiService();
|
||||
|
||||
/// 画像リストから日本酒ラベルを解析
|
||||
Future<SakeAnalysisResult> analyzeSakeLabel(List<String> imagePaths, {bool forceRefresh = false}) async {
|
||||
// クライアント側プロンプトでスキーマの一貫性を保証
|
||||
const prompt = '''
|
||||
// ============================================================
|
||||
// Public API
|
||||
// ============================================================
|
||||
|
||||
/// 画像リストから日本酒ラベルを解析(2段階解析: OCR → フル解析)
|
||||
///
|
||||
/// [onStep1Complete]: Stage 1 完了時に呼ばれるコールバック。
|
||||
/// UI 側でダイアログのメッセージをステージ2用に切り替えるために使う。
|
||||
/// 直接APIモード(consumer APK)のみ有効。プロキシモードは1段階のまま。
|
||||
Future<SakeAnalysisResult> analyzeSakeLabel(
|
||||
List<String> imagePaths, {
|
||||
bool forceRefresh = false,
|
||||
VoidCallback? onStep1Complete,
|
||||
}) async {
|
||||
if (Secrets.useProxy) {
|
||||
return _callProxyApi(
|
||||
imagePaths: imagePaths,
|
||||
customPrompt: _mainAnalysisPrompt,
|
||||
forceRefresh: forceRefresh,
|
||||
);
|
||||
}
|
||||
return _runTwoStageAnalysis(
|
||||
imagePaths,
|
||||
forceRefresh: forceRefresh,
|
||||
onStep1Complete: onStep1Complete,
|
||||
);
|
||||
}
|
||||
|
||||
/// 再解析専用メソッド: 前回の結果を「疑わしい」として渡し、モデルに再考させる
|
||||
Future<SakeAnalysisResult> reanalyzeSakeLabel(
|
||||
List<String> imagePaths, {
|
||||
String? previousName,
|
||||
String? previousBrand,
|
||||
}) async {
|
||||
final prevNameStr = previousName != null ? '「$previousName」' : '不明';
|
||||
final prevBrandStr = previousBrand != null ? '「$previousBrand」' : '不明';
|
||||
|
||||
final challengePrompt = '''
|
||||
【再解析モード — 前回の回答を検証してください】
|
||||
|
||||
前回の解析では以下の結果が返されました:
|
||||
- name(銘柄名): $prevNameStr
|
||||
- brand(蔵元名): $prevBrandStr
|
||||
|
||||
この回答をユーザーが確認し、誤りの可能性があると指摘しました。
|
||||
添付画像を最初から丁寧に見直してください。
|
||||
|
||||
## 【必須確認ステップ】
|
||||
1. ラベル内の文字を1文字ずつ目で追ってください
|
||||
2. 前回の name=$prevNameStr の各漢字がラベルに実際に存在するか確認してください
|
||||
3. 存在しない文字が含まれていれば、ラベルに見えている文字のみに修正してください
|
||||
4. 「ラベルに N 文字しか見えないなら N 文字のみ返す」を厳守してください
|
||||
|
||||
## 【絶対ルール】name・brand・prefectureの読み取り(OCR厳守)
|
||||
- ラベルに印刷されている文字だけを一字一句そのまま出力してください
|
||||
- あなたが知っている「正式名称」への変換・補完は禁止
|
||||
- 【禁止例】「東魁」→「東魁盛」禁止 / 「男山」→「男山本醸造」禁止
|
||||
- ラベルに都道府県名がなければ prefecture は null(推測禁止)
|
||||
|
||||
## その他のフィールド(推定可)
|
||||
ラベル情報+日本酒の一般知識を使って推定してください。
|
||||
|
||||
## 出力形式
|
||||
以下のJSONのみ返す(説明文不要):
|
||||
{
|
||||
"name": "ラベルに写っている銘柄名(補完禁止)",
|
||||
"brand": "ラベルに写っている蔵元名(補完禁止)",
|
||||
"prefecture": "ラベルに書かれた都道府県名(なければnull)",
|
||||
"type": "特定名称(なければnull)",
|
||||
"description": "説明文(100文字程度)",
|
||||
"catchCopy": "20文字以内のキャッチコピー",
|
||||
"confidenceScore": 80,
|
||||
"flavorTags": ["フルーティー", "辛口"],
|
||||
"tasteStats": {"aroma":3,"sweetness":3,"acidity":3,"bitterness":3,"body":3},
|
||||
"alcoholContent": 15.0,
|
||||
"polishingRatio": 50,
|
||||
"sakeMeterValue": 3.0,
|
||||
"riceVariety": null,
|
||||
"yeast": null,
|
||||
"manufacturingYearMonth": null
|
||||
}
|
||||
''';
|
||||
|
||||
return _callDirectApi(
|
||||
imagePaths,
|
||||
challengePrompt,
|
||||
forceRefresh: true,
|
||||
temperature: 0.3,
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 2段階解析(直接APIモード専用)
|
||||
// ============================================================
|
||||
|
||||
/// Stage1(OCR) → Stage2(フル解析) の2段階フロー
|
||||
Future<SakeAnalysisResult> _runTwoStageAnalysis(
|
||||
List<String> imagePaths, {
|
||||
bool forceRefresh = false,
|
||||
VoidCallback? onStep1Complete,
|
||||
}) async {
|
||||
// Stage1実行前にキャッシュ確認(ヒットすれば API 呼び出しなし)
|
||||
if (!forceRefresh && imagePaths.isNotEmpty) {
|
||||
final imageHash = await AnalysisCacheService.computeCombinedHash(imagePaths);
|
||||
final cached = await AnalysisCacheService.getCached(imageHash);
|
||||
if (cached != null) {
|
||||
debugPrint('2-stage: cache hit, skipping API calls');
|
||||
return cached.asCached();
|
||||
}
|
||||
}
|
||||
|
||||
final apiKey = Secrets.geminiApiKey;
|
||||
if (apiKey.isEmpty) throw Exception('Gemini API Key is missing. Please set GEMINI_API_KEY.');
|
||||
|
||||
// 画像を一度だけ読み込み、Stage1/2で共用(ファイルI/O節約)
|
||||
final imageParts = <DataPart>[];
|
||||
for (final path in imagePaths) {
|
||||
final bytes = await File(path).readAsBytes();
|
||||
imageParts.add(DataPart('image/jpeg', bytes));
|
||||
debugPrint('Loaded image for 2-stage: ${(bytes.length / 1024).toStringAsFixed(1)}KB');
|
||||
}
|
||||
|
||||
// --- Stage 1: OCR専念(30秒タイムアウト・失敗時は1段階フォールバック)---
|
||||
Map<String, String?> ocr;
|
||||
try {
|
||||
ocr = await _performOcrStep(apiKey, imageParts);
|
||||
debugPrint('Stage1 OCR: name="${ocr['name']}" brand="${ocr['brand']}" pref="${ocr['prefecture']}"');
|
||||
} catch (e) {
|
||||
debugPrint('Stage1 OCR failed ($e), falling back to single-stage');
|
||||
return _callDirectApi(imagePaths, null, forceRefresh: forceRefresh);
|
||||
}
|
||||
|
||||
// Stage1 で name/brand が両方 null = ラベルを読めなかった → 2段階の意味なし
|
||||
if (ocr['name'] == null && ocr['brand'] == null) {
|
||||
debugPrint('Stage1 returned no text, falling back to single-stage');
|
||||
return _callDirectApi(imagePaths, null, forceRefresh: forceRefresh);
|
||||
}
|
||||
|
||||
// Stage 1 完了を UI に通知(AnalyzingDialog のメッセージを Stage2 用に切り替え)
|
||||
onStep1Complete?.call();
|
||||
|
||||
// --- Stage 2: OCR結果を制約として渡し、残りフィールドを推定 ---
|
||||
// _callDirectApi は内部でキャッシュ保存・リトライを行う(Stage2 も同じ堅牢性を持つ)
|
||||
// forceRefresh=false で呼ぶと内部でキャッシュ再チェックが走るが、
|
||||
// 上の確認でミス済みのため実害なし(ハッシュ計算のみ)
|
||||
final stage2Prompt = _buildStage2Prompt(ocr);
|
||||
return _callDirectApi(imagePaths, stage2Prompt, forceRefresh: forceRefresh);
|
||||
}
|
||||
|
||||
/// Stage 1: ラベルのOCRのみ実行(name / brand / prefecture を確定させる)
|
||||
///
|
||||
/// 軽量プロンプトで素早く文字起こし。補完・変換は完全禁止。
|
||||
/// 失敗時は呼び出し元がフォールバックを担当するため、ここでは rethrow。
|
||||
Future<Map<String, String?>> _performOcrStep(
|
||||
String apiKey,
|
||||
List<DataPart> imageParts,
|
||||
) async {
|
||||
const ocrPrompt = '''
|
||||
日本酒ラベルの画像から、銘柄名・蔵元名・都道府県名の3つだけをOCRしてください。
|
||||
|
||||
【絶対ルール】
|
||||
- ラベルに印刷された文字だけを一字一句そのまま出力する
|
||||
- 補完・変換・拡張は厳禁(例: 「東魁」→「東魁盛」禁止)
|
||||
- ラベルにN文字しかなければN文字のみ出力する
|
||||
|
||||
以下のJSONのみ返す(説明文不要):
|
||||
{"name": "銘柄名", "brand": "蔵元名", "prefecture": "都道府県名またはnull"}
|
||||
''';
|
||||
|
||||
final model = GenerativeModel(
|
||||
model: 'gemini-2.5-flash',
|
||||
apiKey: apiKey,
|
||||
systemInstruction: Content.system(
|
||||
'あなたはOCR専用システムです。ラベルの文字を一字一句正確に書き起こすだけです。'
|
||||
'銘柄名の補完・変換・拡張は厳禁。見えている文字数と出力文字数を一致させること。',
|
||||
),
|
||||
generationConfig: GenerationConfig(
|
||||
responseMimeType: 'application/json',
|
||||
temperature: 0,
|
||||
),
|
||||
);
|
||||
|
||||
final parts = <Part>[TextPart(ocrPrompt), ...imageParts];
|
||||
final response = await model
|
||||
.generateContent([Content.multi(parts)])
|
||||
.timeout(const Duration(seconds: 30));
|
||||
|
||||
final jsonStr = response.text;
|
||||
if (jsonStr == null || jsonStr.isEmpty) {
|
||||
throw Exception('Stage1: empty response');
|
||||
}
|
||||
|
||||
final map = jsonDecode(jsonStr) as Map<String, dynamic>;
|
||||
return {
|
||||
'name': map['name'] as String?,
|
||||
'brand': map['brand'] as String?,
|
||||
'prefecture': map['prefecture'] as String?,
|
||||
};
|
||||
}
|
||||
|
||||
/// Stage 2 用プロンプト: Stage 1 の OCR 結果を「確定済み制約」として埋め込む
|
||||
///
|
||||
/// Gemini は name/brand/prefecture をそのまま出力し、
|
||||
/// 残りフィールドのみ推定に集中することで hallucination を低減する。
|
||||
String _buildStage2Prompt(Map<String, String?> ocr) {
|
||||
final name = ocr['name'];
|
||||
final brand = ocr['brand'];
|
||||
final prefecture = ocr['prefecture'];
|
||||
|
||||
final nameConstraint = name != null ? '「$name」(確定済み — 変更禁止)' : 'null(確定済み)';
|
||||
final brandConstraint = brand != null ? '「$brand」(確定済み — 変更禁止)' : 'null(確定済み)';
|
||||
final prefConstraint = prefecture != null ? '「$prefecture」(確定済み — 変更禁止)' : 'null(確定済み)';
|
||||
|
||||
final nameJson = name != null ? jsonEncode(name) : 'null';
|
||||
final brandJson = brand != null ? jsonEncode(brand) : 'null';
|
||||
final prefJson = prefecture != null ? jsonEncode(prefecture) : 'null';
|
||||
|
||||
return '''
|
||||
あなたは日本酒ラベル解析の専門家です。
|
||||
|
||||
【ステップ1のOCR結果 — 以下3フィールドは変更厳禁】
|
||||
別ステップで画像から厳密にOCRした確定結果です。あなたの知識で書き換えることは絶対に禁止です。
|
||||
- name: $nameConstraint
|
||||
- brand: $brandConstraint
|
||||
- prefecture: $prefConstraint
|
||||
|
||||
上記3フィールドをそのままJSONに含め、残りのフィールドをラベル情報と日本酒知識から推定してください。
|
||||
|
||||
## 推定フィールド(ラベル+一般知識から推定可)
|
||||
- type: ラベルに書かれた特定名称(純米大吟醸など)。なければ null
|
||||
- description: ラベル情報と type から推定した味・特徴の説明(100文字程度)
|
||||
- catchCopy: 20文字以内のキャッチコピー
|
||||
- flavorTags: 味のタグ(フルーティー・辛口・華やか など)
|
||||
- tasteStats: 1〜5の整数。不明なら 3
|
||||
- alcoholContent: ラベルに記載があれば読む。なければ type から推定
|
||||
- polishingRatio: ラベルに記載があれば読む。なければ type から推定
|
||||
- sakeMeterValue: ラベルに記載があれば読む。なければ推定
|
||||
- riceVariety: ラベルに記載があれば読む。なければ null
|
||||
- yeast: ラベルに記載があれば読む。なければ null
|
||||
- manufacturingYearMonth: ラベルに記載があれば読む。なければ null
|
||||
- confidenceScore: 画像の鮮明度・情報量から 0〜100 で評価
|
||||
|
||||
## 出力形式
|
||||
以下のJSONのみ返す(説明文不要):
|
||||
{
|
||||
"name": $nameJson,
|
||||
"brand": $brandJson,
|
||||
"prefecture": $prefJson,
|
||||
"type": "特定名称(なければnull)",
|
||||
"description": "説明文(100文字程度)",
|
||||
"catchCopy": "20文字以内のキャッチコピー",
|
||||
"confidenceScore": 80,
|
||||
"flavorTags": ["フルーティー", "辛口"],
|
||||
"tasteStats": {"aroma":3,"sweetness":3,"acidity":3,"bitterness":3,"body":3},
|
||||
"alcoholContent": 15.0,
|
||||
"polishingRatio": 50,
|
||||
"sakeMeterValue": 3.0,
|
||||
"riceVariety": null,
|
||||
"yeast": null,
|
||||
"manufacturingYearMonth": null
|
||||
}
|
||||
''';
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 既存の1段階プロンプト(プロキシモード・フォールバック用)
|
||||
// ============================================================
|
||||
|
||||
static const String _mainAnalysisPrompt = '''
|
||||
あなたは日本酒ラベル解析の専門家です。
|
||||
添付画像から情報を読み取り、以下のJSONを返してください。
|
||||
|
||||
## 【絶対ルール】name・brand・prefectureの読み取り
|
||||
これら3フィールドのみ、ラベルに印刷された文字を一字一句そのまま出力してください。
|
||||
- あなたの知識でラベルの文字を補完・訂正・変更することは禁止
|
||||
- ラベルに「東魁」→ "name": "東魁"(「東魁盛」に変えない)
|
||||
- ラベルに「白鹿」→ "name": "白鹿"(「白鹿本醸造」に変えない)
|
||||
- ラベルに「久保田」→ "name": "久保田"(「久保田 千寿」に変えない)
|
||||
- prefecture: ラベルに都道府県名が書かれていればそのまま出力、書かれていなければ null
|
||||
(銘柄名から産地を推測して埋めることは禁止)
|
||||
## 【絶対ルール】name・brand・prefectureの読み取り(OCR厳守)
|
||||
これら3フィールドは、ラベルに印刷されている文字だけを一字一句そのまま出力してください。
|
||||
あなたが知っている銘柄知識でラベルの文字を補完・変換・拡張することは厳禁です。
|
||||
|
||||
## その他のフィールド(推定・推定可)
|
||||
以下はラベルから読み取れる情報+日本酒の一般知識を使って推定してください。
|
||||
【具体的な禁止例】
|
||||
- ラベルに「東魁」(2文字) → "東魁" のまま出力(「東魁盛」への変換禁止)
|
||||
- ラベルに「白鹿」(2文字) → "白鹿" のまま出力(「白鹿本醸造」への変換禁止)
|
||||
- ラベルに「久保田」(3文字) → "久保田" のまま出力(「久保田 千寿」への変換禁止)
|
||||
- ラベルに「男山」(2文字) → "男山" のまま出力(「男山本醸造」への変換禁止)
|
||||
- ラベルに「白鶴」(2文字) → "白鶴" のまま出力(「白鶴まる」への変換禁止)
|
||||
- ラベルに「松竹梅」(3文字) → "松竹梅" のまま出力(「松竹梅 白壁蔵」への変換禁止)
|
||||
|
||||
【一般原則】ラベルに N 文字しか見えない場合は N 文字のみ出力する。
|
||||
文字数を増やすことは、たとえあなたが正式名称を知っていても禁止。
|
||||
|
||||
- prefecture: ラベルに都道府県名が書かれていればそのまま出力、なければ null
|
||||
(銘柄名・蔵元名から産地を推測して埋めることは禁止)
|
||||
|
||||
## 【出力前セルフチェック】
|
||||
name・brand を出力する直前に以下を確認してください:
|
||||
- ラベル画像で実際に見えている文字数と、出力しようとしている文字数が一致するか?
|
||||
- あなたの知識による「補完」が入っていないか?
|
||||
不一致の場合は、ラベルに見えている文字数に合わせて修正してください。
|
||||
|
||||
## その他のフィールド(推定可)
|
||||
以下はラベル情報+日本酒の一般知識を使って推定してください。
|
||||
- type: ラベルに書かれた特定名称(純米大吟醸など)。なければ null
|
||||
- description: ラベル情報と type から推定した味・特徴の説明(100文字程度)
|
||||
- catchCopy: 20文字以内のキャッチコピー
|
||||
|
|
@ -71,25 +350,15 @@ class GeminiService {
|
|||
}
|
||||
''';
|
||||
|
||||
return _callProxyApi(
|
||||
imagePaths: imagePaths,
|
||||
customPrompt: prompt, // Override server default
|
||||
forceRefresh: forceRefresh,
|
||||
);
|
||||
}
|
||||
// ============================================================
|
||||
// プロキシ経由APIコール(iOSビルド用 / USE_PROXY=true 時)
|
||||
// ============================================================
|
||||
|
||||
/// 共通実装: ProxyへのAPIコール
|
||||
Future<SakeAnalysisResult> _callProxyApi({
|
||||
required List<String> imagePaths,
|
||||
String? customPrompt,
|
||||
bool forceRefresh = false,
|
||||
}) async {
|
||||
// Check Mode: Direct vs Proxy
|
||||
if (!Secrets.useProxy) {
|
||||
debugPrint('Direct Cloud Mode: Connecting to Gemini API directly...');
|
||||
return _callDirectApi(imagePaths, customPrompt, forceRefresh: forceRefresh);
|
||||
}
|
||||
|
||||
// 1. キャッシュチェック(forceRefresh=false のときのみ)
|
||||
if (!forceRefresh && imagePaths.isNotEmpty) {
|
||||
final imageHash = await AnalysisCacheService.computeCombinedHash(imagePaths);
|
||||
|
|
@ -110,21 +379,20 @@ class GeminiService {
|
|||
}
|
||||
_lastApiCallTime = DateTime.now();
|
||||
|
||||
// 2. 画像をBase64変換(撮影時に圧縮済み)
|
||||
// 3. 画像をBase64変換(撮影時に圧縮済み)
|
||||
List<String> base64Images = [];
|
||||
for (final path in imagePaths) {
|
||||
// Read already-compressed images directly (compressed at capture time)
|
||||
final bytes = await File(path).readAsBytes();
|
||||
final base64String = base64Encode(bytes);
|
||||
base64Images.add(base64String);
|
||||
debugPrint('Encoded processed image: ${(bytes.length / 1024).toStringAsFixed(1)}KB');
|
||||
}
|
||||
|
||||
// 3. デバイスID取得
|
||||
// 4. デバイスID取得
|
||||
final deviceId = await DeviceService.getDeviceId();
|
||||
debugPrint('Device ID: $deviceId');
|
||||
if (kDebugMode) debugPrint('Device ID: $deviceId');
|
||||
|
||||
// 4. リクエスト作成
|
||||
// 5. リクエスト作成
|
||||
final requestBody = jsonEncode({
|
||||
"device_id": deviceId,
|
||||
"images": base64Images,
|
||||
|
|
@ -133,7 +401,7 @@ class GeminiService {
|
|||
|
||||
debugPrint('Calling Proxy: $_proxyUrl');
|
||||
|
||||
// 5. 送信(Bearer Token認証付き)
|
||||
// 6. 送信(Bearer Token認証付き)
|
||||
final headers = {
|
||||
"Content-Type": "application/json",
|
||||
if (Secrets.proxyAuthToken.isNotEmpty)
|
||||
|
|
@ -143,18 +411,16 @@ class GeminiService {
|
|||
Uri.parse(_proxyUrl),
|
||||
headers: headers,
|
||||
body: requestBody,
|
||||
).timeout(const Duration(seconds: 60)); // 拡張: 60秒 (画像サイズ最適化済み)
|
||||
).timeout(const Duration(seconds: 60));
|
||||
|
||||
// 6. レスポンス処理
|
||||
// 7. レスポンス処理
|
||||
if (response.statusCode == 200) {
|
||||
// 成功時のレスポンス形式: { "success": true, "data": {...}, "usage": {...} }
|
||||
final jsonResponse = jsonDecode(utf8.decode(response.bodyBytes));
|
||||
|
||||
if (jsonResponse['success'] == true) {
|
||||
final data = jsonResponse['data'];
|
||||
if (data == null) throw Exception("サーバーからのデータが空です");
|
||||
|
||||
// 使用状況ログ
|
||||
if (jsonResponse['usage'] != null) {
|
||||
final usage = jsonResponse['usage'];
|
||||
debugPrint('API Usage: ${usage['today']}/${usage['limit']}');
|
||||
|
|
@ -162,9 +428,6 @@ class GeminiService {
|
|||
|
||||
final result = SakeAnalysisResult.fromJson(data);
|
||||
|
||||
// tasteStats の補完・バリデーションは SakeAnalysisResult.fromJson 内で実施済み
|
||||
|
||||
// キャッシュに保存(次回同一画像はAPI不使用)
|
||||
if (imagePaths.isNotEmpty) {
|
||||
final imageHash = await AnalysisCacheService.computeCombinedHash(imagePaths);
|
||||
await AnalysisCacheService.saveCache(imageHash, result);
|
||||
|
|
@ -177,11 +440,9 @@ class GeminiService {
|
|||
|
||||
return result;
|
||||
} else {
|
||||
// Proxy側での論理エラー (レート制限超過など)
|
||||
throw Exception(jsonResponse['error'] ?? '不明なエラーが発生しました');
|
||||
}
|
||||
} else {
|
||||
// HTTPエラー
|
||||
if (kDebugMode) {
|
||||
debugPrint('Proxy Error: ${response.statusCode} ${response.body}');
|
||||
}
|
||||
|
|
@ -190,7 +451,6 @@ class GeminiService {
|
|||
|
||||
} catch (e) {
|
||||
debugPrint('Proxy Call Failed: $e');
|
||||
// エラーメッセージを整形
|
||||
final errorMsg = e.toString().toLowerCase();
|
||||
if (errorMsg.contains('limit') || errorMsg.contains('上限')) {
|
||||
throw Exception('本日のAI解析リクエスト上限に達しました。\n明日またお試しください。');
|
||||
|
|
@ -199,10 +459,16 @@ class GeminiService {
|
|||
}
|
||||
}
|
||||
|
||||
/// Direct Cloud API Implementation (No Proxy)
|
||||
Future<SakeAnalysisResult> _callDirectApi(List<String> imagePaths, String? customPrompt, {bool forceRefresh = false}) async {
|
||||
// 1. キャッシュチェック(同じ画像なら即座に返す)
|
||||
// forceRefresh=trueの場合はキャッシュをスキップ
|
||||
// ============================================================
|
||||
// 直接APIコール(consumer APK 用 / USE_PROXY=false 時)
|
||||
// ============================================================
|
||||
|
||||
Future<SakeAnalysisResult> _callDirectApi(
|
||||
List<String> imagePaths,
|
||||
String? customPrompt, {
|
||||
bool forceRefresh = false,
|
||||
double temperature = 0,
|
||||
}) async {
|
||||
if (!forceRefresh && imagePaths.isNotEmpty) {
|
||||
final imageHash = await AnalysisCacheService.computeCombinedHash(imagePaths);
|
||||
final cached = await AnalysisCacheService.getCached(imageHash);
|
||||
|
|
@ -212,63 +478,22 @@ class GeminiService {
|
|||
}
|
||||
}
|
||||
|
||||
// 2. API Key確認
|
||||
final apiKey = Secrets.geminiApiKey;
|
||||
if (apiKey.isEmpty) {
|
||||
throw Exception('Gemini API Key is missing. Please set GEMINI_API_KEY.');
|
||||
}
|
||||
|
||||
// モデル候補: 503/UNAVAILABLE 時にフォールバック
|
||||
// NOTE: Google は予告なしでモデルを廃止することがある。定期的に動作確認を行うこと。
|
||||
// Phase 2(プロキシ移行)後はサーバー側から設定を取得する設計に変更する予定。
|
||||
const primaryModel = 'gemini-2.5-flash';
|
||||
const fallbackModel = 'gemini-2.0-flash';
|
||||
const fallbackModel = 'gemini-2.5-flash-lite';
|
||||
|
||||
// customPrompt は analyzeSakeLabel から常に渡される。null になるケースは通常存在しない。
|
||||
final promptText = customPrompt ?? '''
|
||||
あなたは日本酒ラベル解析の専門家です。
|
||||
添付画像から情報を読み取り、以下のJSONを返してください。
|
||||
final promptText = customPrompt ?? _mainAnalysisPrompt;
|
||||
|
||||
## 【絶対ルール】name・brand・prefectureの読み取り
|
||||
これら3フィールドのみ、ラベルに印刷された文字を一字一句そのまま出力してください。
|
||||
- あなたの知識でラベルの文字を補完・訂正・変更することは禁止
|
||||
- ラベルに「東魁」→ "name": "東魁"(「東魁盛」に変えない)
|
||||
- prefecture: ラベルに都道府県名が書かれていればそのまま出力、なければ null(推測禁止)
|
||||
|
||||
## その他のフィールド(推定可)
|
||||
ラベル情報+日本酒の一般知識を使って推定してください。
|
||||
- tasteStats: 1〜5の整数。不明なら 3
|
||||
- alcoholContent・polishingRatio: ラベルに記載があれば読む。なければ type から推定
|
||||
|
||||
## 出力形式
|
||||
以下のJSONのみ返す(説明文不要):
|
||||
{
|
||||
"name": "ラベルに写っている銘柄名の文字(一字一句そのまま・補完禁止)",
|
||||
"brand": "ラベルに写っている蔵元名の文字(一字一句そのまま・補完禁止)",
|
||||
"prefecture": "ラベルに書かれた都道府県名(なければnull・推測禁止)",
|
||||
"type": "特定名称(ラベルから読む。なければnull)",
|
||||
"description": "ラベル情報とtypeから推定した説明文(100文字程度)",
|
||||
"catchCopy": "20文字以内のキャッチコピー",
|
||||
"confidenceScore": 80,
|
||||
"flavorTags": ["フルーティー", "辛口"],
|
||||
"tasteStats": {"aroma":3,"sweetness":3,"acidity":3,"bitterness":3,"body":3},
|
||||
"alcoholContent": 15.0,
|
||||
"polishingRatio": 50,
|
||||
"sakeMeterValue": 3.0,
|
||||
"riceVariety": "山田錦",
|
||||
"yeast": "きょうかい9号",
|
||||
"manufacturingYearMonth": "2023.10"
|
||||
}
|
||||
''';
|
||||
|
||||
// Prepare Content parts (画像バイト読み込みは一度だけ)
|
||||
final contentParts = <Part>[TextPart(promptText)];
|
||||
for (var path in imagePaths) {
|
||||
final bytes = await File(path).readAsBytes();
|
||||
contentParts.add(DataPart('image/jpeg', bytes));
|
||||
}
|
||||
|
||||
// 503 時: リトライ(指数バックオフ)→ フォールバックモデル
|
||||
const maxRetries = 3;
|
||||
final modelsToTry = [primaryModel, primaryModel, primaryModel, fallbackModel];
|
||||
|
||||
|
|
@ -287,14 +512,19 @@ class GeminiService {
|
|||
model: modelName,
|
||||
apiKey: apiKey,
|
||||
systemInstruction: Content.system(
|
||||
'あなたは画像内のテキストを一字一句正確に読み取る専門家です。'
|
||||
'ラベルに記載された銘柄名・蔵元名は絶対に変更・補完しないでください。'
|
||||
'あなたの知識でラベルの文字を上書きすることは厳禁です。'
|
||||
'ラベルに「東魁」とあれば、「東魁盛」を知っていても必ず「東魁」と出力してください。',
|
||||
'あなたは画像内のテキストを一字一句正確に書き起こすOCR(光学文字認識)専門システムです。\n'
|
||||
'【絶対的制約 — name・brand・prefecture フィールドに適用】\n'
|
||||
'1. ラベルに印刷されている文字だけを出力する。ラベルにない文字を1文字も追加してはならない。\n'
|
||||
'2. ラベルに N 文字の銘柄名があれば N 文字のまま出力する。文字数を増やすことは禁止。\n'
|
||||
'3. あなたが知っている「正式名称」「有名銘柄名」への変換・補完は禁止。\n'
|
||||
' 例: 「東魁」→「東魁」(「東魁盛」禁止)、「男山」→「男山」(「男山本醸造」禁止)、\n'
|
||||
' 「白鹿」→「白鹿」(「白鹿本醸造」禁止)、「久保田」→「久保田」(「久保田 千寿」禁止)\n'
|
||||
'4. ラベルに都道府県名がなければ prefecture は null。銘柄名から産地を推測して埋めることは禁止。\n'
|
||||
'5. 日本酒知識は description・flavorTags・tasteStats 等の推定フィールドにのみ使用すること。',
|
||||
),
|
||||
generationConfig: GenerationConfig(
|
||||
responseMimeType: 'application/json',
|
||||
temperature: 0,
|
||||
temperature: temperature,
|
||||
),
|
||||
);
|
||||
|
||||
|
|
@ -308,11 +538,9 @@ class GeminiService {
|
|||
final jsonMap = jsonDecode(jsonString);
|
||||
final result = SakeAnalysisResult.fromJson(jsonMap);
|
||||
|
||||
// 3. キャッシュに保存(次回は即座に返せる)
|
||||
if (imagePaths.isNotEmpty) {
|
||||
final imageHash = await AnalysisCacheService.computeCombinedHash(imagePaths);
|
||||
await AnalysisCacheService.saveCache(imageHash, result);
|
||||
// 4. 銘柄名インデックスに登録(forceRefresh 時は誤認識結果を上書き)
|
||||
await AnalysisCacheService.registerBrandIndex(
|
||||
result.name,
|
||||
imageHash,
|
||||
|
|
@ -329,22 +557,20 @@ class GeminiService {
|
|||
debugPrint('Direct API Error (attempt $attempt, model: $modelName): $e');
|
||||
|
||||
if (isLastAttempt || !is503) {
|
||||
// 最終試行 or 503以外のエラーはそのまま投げる
|
||||
if (is503) {
|
||||
throw const GeminiCongestionException();
|
||||
}
|
||||
if (is503) throw const GeminiCongestionException();
|
||||
throw Exception('AI解析エラー(Direct): $e');
|
||||
}
|
||||
// 503 → 次のリトライへ
|
||||
}
|
||||
}
|
||||
|
||||
// ここには到達しない
|
||||
throw Exception('AI解析に失敗しました。再試行してください。');
|
||||
}
|
||||
}
|
||||
|
||||
// Analysis Result Model
|
||||
// ============================================================
|
||||
// Data Models
|
||||
// ============================================================
|
||||
|
||||
class SakeAnalysisResult {
|
||||
final String? name;
|
||||
final String? brand;
|
||||
|
|
@ -356,7 +582,6 @@ class SakeAnalysisResult {
|
|||
final List<String> flavorTags;
|
||||
final Map<String, int> tasteStats;
|
||||
|
||||
// New Fields
|
||||
final double? alcoholContent;
|
||||
final int? polishingRatio;
|
||||
final double? sakeMeterValue;
|
||||
|
|
@ -365,7 +590,6 @@ class SakeAnalysisResult {
|
|||
final String? manufacturingYearMonth;
|
||||
|
||||
/// キャッシュから返された結果かどうか(EXP付与・新規登録の判定に使用)
|
||||
/// JSON には含まない(キャッシュ保存・復元時は常に false)
|
||||
final bool isFromCache;
|
||||
|
||||
SakeAnalysisResult({
|
||||
|
|
@ -387,7 +611,6 @@ class SakeAnalysisResult {
|
|||
this.isFromCache = false,
|
||||
});
|
||||
|
||||
/// キャッシュヒット用コピー
|
||||
SakeAnalysisResult asCached() => SakeAnalysisResult(
|
||||
name: name, brand: brand, prefecture: prefecture, type: type,
|
||||
description: description, catchCopy: catchCopy, confidenceScore: confidenceScore,
|
||||
|
|
@ -399,7 +622,6 @@ class SakeAnalysisResult {
|
|||
);
|
||||
|
||||
factory SakeAnalysisResult.fromJson(Map<String, dynamic> json) {
|
||||
// tasteStats: 欠損キーを 3 で補完、範囲外(1〜5)をクランプ
|
||||
const requiredStatKeys = ['aroma', 'sweetness', 'acidity', 'bitterness', 'body'];
|
||||
Map<String, int> stats = {};
|
||||
if (json['tasteStats'] is Map) {
|
||||
|
|
@ -432,7 +654,6 @@ class SakeAnalysisResult {
|
|||
);
|
||||
}
|
||||
|
||||
/// JSON形式に変換(キャッシュ保存用)
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'name': name,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'device_service.dart';
|
||||
|
|
@ -22,43 +23,81 @@ enum LicenseStatus {
|
|||
|
||||
/// ライセンス管理サービス
|
||||
///
|
||||
/// ## ストレージ方針
|
||||
/// - ライセンスキー本体: flutter_secure_storage(暗号化)
|
||||
/// - 状態キャッシュ: SharedPreferences(平文でもリスクなし)
|
||||
///
|
||||
/// ## 状態管理の優先順位
|
||||
/// 1. オンライン時: VPSに問い合わせた結果を使用 + SharedPreferencesにキャッシュ
|
||||
/// 2. オフライン時: SharedPreferencesのキャッシュを使用 (Pro状態を維持)
|
||||
/// 1. 起動時: SecureStorage のキャッシュを即時返却(ちらつき防止)
|
||||
/// 2. バックグラウンド: VPS で再検証し、差異があれば状態を更新
|
||||
/// 3. オフライン時: SharedPreferences のキャッシュを維持
|
||||
///
|
||||
/// ## ライセンスキー形式
|
||||
/// PONSHU-XXXX-XXXX-XXXX (6バイト = 12hex文字, 大文字)
|
||||
class LicenseService {
|
||||
static const _prefLicenseKey = 'ponshu_license_key';
|
||||
static const _secureKeyName = 'ponshu_license_key';
|
||||
static const _prefCachedStatus = 'ponshu_license_status_cache';
|
||||
static const _prefCachedAt = 'ponshu_license_cached_at';
|
||||
static const _cacheValidSeconds = 24 * 60 * 60; // 24時間キャッシュ有効
|
||||
static const _prefMigratedV1 = 'ponshu_license_migrated_v1';
|
||||
static const _cacheValidSeconds = 24 * 60 * 60; // 24時間
|
||||
|
||||
static const _storage = FlutterSecureStorage(
|
||||
aOptions: AndroidOptions(encryptedSharedPreferences: true),
|
||||
);
|
||||
|
||||
// ========== Migration ==========
|
||||
|
||||
/// SharedPreferences → flutter_secure_storage の一回限りのマイグレーション
|
||||
static Future<void> _migrateIfNeeded() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
if (prefs.getBool(_prefMigratedV1) == true) return;
|
||||
|
||||
// 旧ストレージにキーがあれば移行して削除
|
||||
final oldValue = prefs.getString(_secureKeyName);
|
||||
if (oldValue != null && oldValue.isNotEmpty) {
|
||||
await _storage.write(key: _secureKeyName, value: oldValue);
|
||||
await prefs.remove(_secureKeyName);
|
||||
debugPrint('[License] Migrated license key to secure storage.');
|
||||
}
|
||||
await prefs.setBool(_prefMigratedV1, true);
|
||||
}
|
||||
|
||||
// ========== Public API ==========
|
||||
|
||||
/// アプリ起動時に呼ぶ: ライセンス状態を確認して返す
|
||||
static Future<LicenseStatus> checkStatus() async {
|
||||
/// キャッシュのみを即時返却(サーバー問い合わせなし)
|
||||
///
|
||||
/// 起動時のちらつき防止用。main() で await してから runApp() に渡す。
|
||||
static Future<LicenseStatus> getCachedStatusOnly() async {
|
||||
await _migrateIfNeeded();
|
||||
final savedKey = await _storage.read(key: _secureKeyName) ?? '';
|
||||
if (savedKey.isEmpty) return LicenseStatus.free;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final savedKey = prefs.getString(_prefLicenseKey) ?? '';
|
||||
return _getCachedStatus(prefs);
|
||||
}
|
||||
|
||||
/// アプリ起動時(バックグラウンド): VPS でライセンス状態を検証して返す
|
||||
static Future<LicenseStatus> checkStatus() async {
|
||||
await _migrateIfNeeded();
|
||||
final savedKey = await _storage.read(key: _secureKeyName) ?? '';
|
||||
|
||||
// ライセンスキーが保存済み → サーバーで検証
|
||||
if (savedKey.isNotEmpty) {
|
||||
try {
|
||||
final status = await _validateKeyWithServer(savedKey);
|
||||
if (status == LicenseStatus.offline) {
|
||||
// ネットワーク不通: キャッシュを上書きせずに返す
|
||||
debugPrint('[License] Server unreachable, using cache');
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return _getCachedStatus(prefs);
|
||||
}
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await _cacheStatus(prefs, status);
|
||||
return status;
|
||||
} catch (e) {
|
||||
debugPrint('[License] Server unreachable, using cache: $e');
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return _getCachedStatus(prefs);
|
||||
}
|
||||
}
|
||||
|
||||
// ライセンスキーなし → 無料版
|
||||
return LicenseStatus.free;
|
||||
}
|
||||
|
||||
|
|
@ -76,8 +115,8 @@ class LicenseService {
|
|||
final status = await _validateKeyWithServer(key);
|
||||
|
||||
if (status == LicenseStatus.pro) {
|
||||
await _storage.write(key: _secureKeyName, value: key);
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_prefLicenseKey, key);
|
||||
await _cacheStatus(prefs, LicenseStatus.pro);
|
||||
debugPrint('[License] Activated successfully.');
|
||||
return (success: true, message: '');
|
||||
|
|
@ -96,14 +135,14 @@ class LicenseService {
|
|||
|
||||
/// ライセンスキーがローカルに保存されているか
|
||||
static Future<bool> hasLicenseKey() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return (prefs.getString(_prefLicenseKey) ?? '').isNotEmpty;
|
||||
await _migrateIfNeeded();
|
||||
return ((await _storage.read(key: _secureKeyName)) ?? '').isNotEmpty;
|
||||
}
|
||||
|
||||
/// ライセンスをリセット(デバッグ用)
|
||||
static Future<void> reset() async {
|
||||
await _storage.delete(key: _secureKeyName);
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove(_prefLicenseKey);
|
||||
await prefs.remove(_prefCachedStatus);
|
||||
await prefs.remove(_prefCachedAt);
|
||||
debugPrint('[License] Reset complete.');
|
||||
|
|
@ -128,7 +167,11 @@ class LicenseService {
|
|||
final data = jsonDecode(utf8.decode(response.bodyBytes)) as Map<String, dynamic>;
|
||||
|
||||
if (data['valid'] == true) return LicenseStatus.pro;
|
||||
if ((data['error'] as String? ?? '').contains('無効化')) return LicenseStatus.revoked;
|
||||
// revoked フィールド(boolean)を優先し、error メッセージ文字列にも対応
|
||||
if (data['revoked'] == true ||
|
||||
(data['error'] as String? ?? '').contains('無効化')) {
|
||||
return LicenseStatus.revoked;
|
||||
}
|
||||
return LicenseStatus.free;
|
||||
|
||||
} catch (e) {
|
||||
|
|
@ -148,17 +191,21 @@ class LicenseService {
|
|||
|
||||
if (cached == null) return LicenseStatus.free;
|
||||
|
||||
// キャッシュが古すぎる場合はfreeにフォールバック
|
||||
// pro と revoked は期限切れにしない(proは購入者を締め出さない、revokedは誤って復活させない)
|
||||
// オンライン時は checkStatus が常に上書きするため、
|
||||
// _getCachedStatus はオフライン時専用のフォールバックとして動作する。
|
||||
//
|
||||
// TTL 判定(_cacheValidSeconds = 24h):
|
||||
// - free / offline: 期限切れで free にフォールバック
|
||||
// - pro : 購入者をオフライン時に締め出さないため永続扱い
|
||||
// - revoked: 不正防止を優先するため永続扱い
|
||||
if (cachedAt != null) {
|
||||
final age = DateTime.now().difference(DateTime.parse(cachedAt));
|
||||
final isPermanentStatus = cached == LicenseStatus.pro.name || cached == LicenseStatus.revoked.name;
|
||||
if (age.inSeconds > _cacheValidSeconds && !isPermanentStatus) {
|
||||
final isNoExpiryStatus = cached == LicenseStatus.pro.name || cached == LicenseStatus.revoked.name;
|
||||
if (age.inSeconds > _cacheValidSeconds && !isNoExpiryStatus) {
|
||||
return LicenseStatus.free;
|
||||
}
|
||||
}
|
||||
|
||||
// Pro キャッシュはオフラインでも維持(購入者を締め出さない)
|
||||
return LicenseStatus.values.firstWhere(
|
||||
(s) => s.name == cached,
|
||||
orElse: () => LicenseStatus.free,
|
||||
|
|
|
|||
|
|
@ -159,12 +159,10 @@ class SakenowaAutoMatchingService {
|
|||
sakenowaFlavorChart: flavorChartMap,
|
||||
);
|
||||
|
||||
// SakeItem更新
|
||||
sakeItem.displayData = updatedDisplayData;
|
||||
sakeItem.hiddenSpecs = updatedHiddenSpecs;
|
||||
|
||||
// Hiveに保存
|
||||
await sakeItem.save();
|
||||
await sakeItem.applyUpdates(
|
||||
displayData: updatedDisplayData,
|
||||
hiddenSpecs: updatedHiddenSpecs,
|
||||
);
|
||||
|
||||
debugPrint(' [SakenowaAutoMatching] 適用完了!');
|
||||
debugPrint(' displayName: ${sakeItem.displayData.displayName}');
|
||||
|
|
@ -226,10 +224,10 @@ class SakenowaAutoMatchingService {
|
|||
sakenowaFlavorChart: null,
|
||||
);
|
||||
|
||||
sakeItem.displayData = clearedDisplayData;
|
||||
sakeItem.hiddenSpecs = clearedHiddenSpecs;
|
||||
|
||||
await sakeItem.save();
|
||||
await sakeItem.applyUpdates(
|
||||
displayData: clearedDisplayData,
|
||||
hiddenSpecs: clearedHiddenSpecs,
|
||||
);
|
||||
|
||||
debugPrint(' [SakenowaAutoMatching] クリア完了');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -52,7 +52,12 @@ class _AddSetItemDialogState extends ConsumerState<AddSetItemDialog> {
|
|||
});
|
||||
}
|
||||
} catch (e) {
|
||||
// Handle error
|
||||
debugPrint('Image pick error: $e');
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('画像の選択に失敗しました。再度お試しください。')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -104,7 +109,7 @@ class _AddSetItemDialogState extends ConsumerState<AddSetItemDialog> {
|
|||
final newlyUnlockedBadges = await GamificationService.checkAndUnlockBadges(ref);
|
||||
|
||||
if (newlyUnlockedBadges.isNotEmpty) {
|
||||
debugPrint('🏅 Badges Unlocked: ${newlyUnlockedBadges.map((b) => b.name).join(", ")}');
|
||||
debugPrint('[Gamification] Badges Unlocked: ${newlyUnlockedBadges.map((b) => b.name).join(", ")}');
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,12 @@ import 'package:flutter/material.dart';
|
|||
import 'dart:async';
|
||||
|
||||
class AnalyzingDialog extends StatefulWidget {
|
||||
const AnalyzingDialog({super.key});
|
||||
/// Stage 通知用 ValueNotifier。
|
||||
/// null のとき(プロキシモード・キャッシュヒット後など)は Stage1 メッセージのみを表示する。
|
||||
/// value が 2 に変わると Stage2 メッセージセットに切り替わる。
|
||||
final ValueNotifier<int>? stageNotifier;
|
||||
|
||||
const AnalyzingDialog({super.key, this.stageNotifier});
|
||||
|
||||
@override
|
||||
State<AnalyzingDialog> createState() => _AnalyzingDialogState();
|
||||
|
|
@ -10,32 +15,70 @@ class AnalyzingDialog extends StatefulWidget {
|
|||
|
||||
class _AnalyzingDialogState extends State<AnalyzingDialog> {
|
||||
int _messageIndex = 0;
|
||||
Timer? _timer;
|
||||
|
||||
final List<String> _messages = [
|
||||
'ラベルを読んでいます...',
|
||||
'銘柄を確認しています...',
|
||||
static const _stage1Messages = [
|
||||
'ラベルを読み取っています...',
|
||||
'文字を一字一句確認中...',
|
||||
];
|
||||
|
||||
static const _stage2Messages = [
|
||||
'この日本酒の個性を分析中...',
|
||||
'フレーバーチャートを描画しています...',
|
||||
'素敵なキャッチコピーを考えています...',
|
||||
];
|
||||
|
||||
List<String> get _currentMessages =>
|
||||
(_stage == 2) ? _stage2Messages : _stage1Messages;
|
||||
|
||||
int _stage = 1;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
widget.stageNotifier?.addListener(_onStageChanged);
|
||||
_startMessageRotation();
|
||||
}
|
||||
|
||||
void _startMessageRotation() {
|
||||
Future.delayed(const Duration(milliseconds: 1500), () {
|
||||
if (mounted && _messageIndex < _messages.length - 1) {
|
||||
setState(() => _messageIndex++);
|
||||
void _onStageChanged() {
|
||||
final newStage = widget.stageNotifier?.value ?? 1;
|
||||
if (newStage != _stage) {
|
||||
_timer?.cancel();
|
||||
setState(() {
|
||||
_stage = newStage;
|
||||
_messageIndex = 0;
|
||||
});
|
||||
_startMessageRotation();
|
||||
}
|
||||
}
|
||||
|
||||
void _startMessageRotation() {
|
||||
_timer = Timer.periodic(const Duration(milliseconds: 1800), (timer) {
|
||||
if (!mounted) {
|
||||
timer.cancel();
|
||||
return;
|
||||
}
|
||||
final messages = _currentMessages;
|
||||
if (_messageIndex < messages.length - 1) {
|
||||
setState(() => _messageIndex++);
|
||||
} else {
|
||||
timer.cancel();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_timer?.cancel();
|
||||
widget.stageNotifier?.removeListener(_onStageChanged);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final messages = _currentMessages;
|
||||
final safeIndex = _messageIndex.clamp(0, messages.length - 1);
|
||||
|
||||
return Dialog(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
|
|
@ -45,10 +88,19 @@ class _AnalyzingDialogState extends State<AnalyzingDialog> {
|
|||
const CircularProgressIndicator(),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
_messages[_messageIndex],
|
||||
messages[safeIndex],
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
if (widget.stageNotifier != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'ステップ $_stage / 2',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.4),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import '../../providers/sake_list_provider.dart';
|
||||
import '../../models/schema/item_type.dart';
|
||||
import '../../services/api_usage_service.dart';
|
||||
import '../../theme/app_colors.dart';
|
||||
|
||||
class ActivityStats extends ConsumerWidget {
|
||||
|
|
@ -12,6 +13,8 @@ class ActivityStats extends ConsumerWidget {
|
|||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final allSakeAsync = ref.watch(allSakeItemsProvider);
|
||||
|
||||
final apiUsageAsync = ref.watch(apiUsageCountProvider);
|
||||
|
||||
return allSakeAsync.when(
|
||||
data: (sakes) {
|
||||
final individualSakes = sakes.where((s) => s.itemType == ItemType.sake).toList();
|
||||
|
|
@ -25,7 +28,17 @@ class ActivityStats extends ConsumerWidget {
|
|||
}).toSet();
|
||||
final recordingDays = dates.length;
|
||||
|
||||
final apiCount = apiUsageAsync.asData?.value ?? 0;
|
||||
final remaining = (ApiUsageService.dailyLimit - apiCount).clamp(0, ApiUsageService.dailyLimit);
|
||||
final isExhausted = remaining == 0;
|
||||
final isLow = remaining <= 5 && !isExhausted;
|
||||
|
||||
final appColors = Theme.of(context).extension<AppColors>()!;
|
||||
final apiColor = isExhausted
|
||||
? appColors.error
|
||||
: isLow
|
||||
? appColors.warning
|
||||
: appColors.brandPrimary;
|
||||
|
||||
// Bento Grid: 総登録数を大カード、お気に入り・撮影日数を小カード2枚横並び
|
||||
return Column(
|
||||
|
|
@ -134,6 +147,57 @@ class ActivityStats extends ConsumerWidget {
|
|||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// AI 使用状況カード
|
||||
_BentoCard(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(LucideIcons.sparkles, size: 14, color: apiColor),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'今日のAI解析',
|
||||
style: TextStyle(fontSize: 11, color: appColors.textSecondary),
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
'$apiCount / ${ApiUsageService.dailyLimit}回',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: apiColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: LinearProgressIndicator(
|
||||
value: apiCount / ApiUsageService.dailyLimit,
|
||||
backgroundColor: appColors.surfaceSubtle,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(apiColor),
|
||||
minHeight: 5,
|
||||
),
|
||||
),
|
||||
if (isExhausted) ...[
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'本日の上限に達しました。写真は保存されています。',
|
||||
style: TextStyle(fontSize: 10, color: appColors.error),
|
||||
),
|
||||
] else if (isLow) ...[
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'残り$remaining回です。',
|
||||
style: TextStyle(fontSize: 10, color: appColors.warning),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -188,6 +188,8 @@ class SakeListItem extends ConsumerWidget {
|
|||
if (!isMenuMode && sake.itemType != ItemType.set) ...[
|
||||
const SizedBox(height: 6),
|
||||
_buildSpecLine(context, appColors),
|
||||
const SizedBox(height: 4),
|
||||
_buildRecordedDate(appColors),
|
||||
],
|
||||
],
|
||||
),
|
||||
|
|
@ -200,6 +202,36 @@ class SakeListItem extends ConsumerWidget {
|
|||
); // Pressable
|
||||
}
|
||||
|
||||
/// 記録日(相対表示)
|
||||
Widget _buildRecordedDate(AppColors appColors) {
|
||||
final date = sake.metadata.createdAt;
|
||||
final now = DateTime.now();
|
||||
final today = DateTime(now.year, now.month, now.day);
|
||||
final itemDay = DateTime(date.year, date.month, date.day);
|
||||
final diff = today.difference(itemDay).inDays;
|
||||
|
||||
final String label;
|
||||
if (diff == 0) {
|
||||
label = '今日';
|
||||
} else if (diff == 1) {
|
||||
label = '昨日';
|
||||
} else if (diff < 7) {
|
||||
label = '$diff日前';
|
||||
} else if (date.year == now.year) {
|
||||
label = '${date.month}/${date.day}';
|
||||
} else {
|
||||
label = '${date.year}/${date.month}/${date.day}';
|
||||
}
|
||||
|
||||
return Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: appColors.textTertiary,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 特定名称 + アルコール度数(どちらもある場合は両方、片方のみも対応)
|
||||
Widget _buildSpecLine(BuildContext context, AppColors appColors) {
|
||||
final type = sake.hiddenSpecs.type;
|
||||
|
|
|
|||
|
|
@ -2,19 +2,21 @@ import 'package:flutter/material.dart';
|
|||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../providers/filter_providers.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import '../../theme/app_colors.dart';
|
||||
|
||||
class SakeNoMatchState extends ConsumerWidget {
|
||||
const SakeNoMatchState({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final appColors = Theme.of(context).extension<AppColors>()!;
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(LucideIcons.filterX, size: 48, color: Colors.grey[400]),
|
||||
Icon(LucideIcons.filterX, size: 48, color: appColors.textTertiary),
|
||||
const SizedBox(height: 16),
|
||||
Text('条件に一致するお酒が見つかりません', style: TextStyle(color: Colors.grey[600])),
|
||||
Text('条件に一致するお酒が見つかりません', style: TextStyle(color: appColors.textTertiary)),
|
||||
TextButton(
|
||||
child: const Text('フィルタを解除'),
|
||||
onPressed: () {
|
||||
|
|
|
|||
|
|
@ -76,7 +76,7 @@ class PrefectureTileMap extends ConsumerWidget {
|
|||
if (v.contains(prefName)) prefId = k;
|
||||
});
|
||||
final regionId = JapanMapData.getRegionId(prefId);
|
||||
final regionColor = regionColors[regionId] ?? Colors.grey;
|
||||
final regionColor = regionColors[regionId] ?? appColors.divider;
|
||||
|
||||
Color baseColor;
|
||||
Color textColor;
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import '../services/draft_service.dart';
|
||||
import '../screens/pending_analysis_screen.dart';
|
||||
import '../theme/app_colors.dart';
|
||||
|
||||
/// 未解析Draft(解析待ちアイテム)通知バナー
|
||||
///
|
||||
|
|
@ -24,19 +25,21 @@ class PendingAnalysisBanner extends ConsumerWidget {
|
|||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final appColors = Theme.of(context).extension<AppColors>()!;
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
Colors.orange.shade600,
|
||||
Colors.orange.shade400,
|
||||
appColors.warning,
|
||||
appColors.warning.withValues(alpha: 0.85),
|
||||
],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.orange.withValues(alpha: 0.3),
|
||||
color: appColors.warning.withValues(alpha: 0.3),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
|
|
@ -98,7 +101,7 @@ class PendingAnalysisBanner extends ConsumerWidget {
|
|||
child: Text(
|
||||
'$pendingCount件',
|
||||
style: TextStyle(
|
||||
color: Colors.orange.shade700,
|
||||
color: appColors.warning,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -109,6 +109,10 @@ class _Sake3DCarouselState extends State<Sake3DCarousel> {
|
|||
? Image.file(
|
||||
File(item.displayData.imagePaths.first),
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) => Container(
|
||||
color: Colors.grey[300],
|
||||
child: const Icon(Icons.broken_image, size: 50, color: Colors.grey),
|
||||
),
|
||||
)
|
||||
: Container(
|
||||
color: Colors.grey[300],
|
||||
|
|
|
|||
|
|
@ -164,6 +164,10 @@ class _Sake3DCarouselWithReasonState extends State<Sake3DCarouselWithReason> {
|
|||
? Image.file(
|
||||
File(rec.item.displayData.imagePaths.first),
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) => Container(
|
||||
color: Colors.grey[300],
|
||||
child: Icon(LucideIcons.imageOff, size: 50, color: Colors.grey[600]),
|
||||
),
|
||||
)
|
||||
: Container(
|
||||
color: Colors.grey[300],
|
||||
|
|
|
|||
|
|
@ -75,9 +75,9 @@ class _SakeDetailSpecsState extends State<SakeDetailSpecs> {
|
|||
if (_isEditing) {
|
||||
// Warn user about external update while editing
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('データが外部から更新されました。編集をキャンセルして再度お試しください。'),
|
||||
backgroundColor: Colors.orange,
|
||||
SnackBar(
|
||||
content: const Text('データが外部から更新されました。編集をキャンセルして再度お試しください。'),
|
||||
backgroundColor: Theme.of(context).extension<AppColors>()!.warning,
|
||||
),
|
||||
);
|
||||
_cancel(); // Force exit edit mode
|
||||
|
|
@ -272,6 +272,7 @@ class _SakeDetailSpecsState extends State<SakeDetailSpecs> {
|
|||
_isEditing,
|
||||
suffixIcon: LucideIcons.calendar,
|
||||
onSuffixTap: () => _showDatePicker(context),
|
||||
helperText: '例: 2023-10',
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -303,10 +304,11 @@ class _SakeDetailSpecsState extends State<SakeDetailSpecs> {
|
|||
void _showDatePicker(BuildContext context) {
|
||||
if (!_isEditing) return;
|
||||
|
||||
// Parse current value or use now
|
||||
// Parse current value or use now (AI出力 "2023.10" とユーザー入力 "2023-10" の両形式に対応)
|
||||
DateTime initialDate = DateTime.now();
|
||||
try {
|
||||
final parts = _manufacturingController.text.split('-');
|
||||
final normalized = _manufacturingController.text.replaceAll('.', '-');
|
||||
final parts = normalized.split('-');
|
||||
if (parts.length >= 2) {
|
||||
final year = int.parse(parts[0]);
|
||||
final month = int.parse(parts[1]);
|
||||
|
|
|
|||
|
|
@ -70,7 +70,12 @@ class SakeSearchDelegate extends SearchDelegate {
|
|||
width: 40,
|
||||
height: 40,
|
||||
child: sake.displayData.imagePaths.isNotEmpty
|
||||
? Image.file(File(sake.displayData.imagePaths.first), fit: BoxFit.cover)
|
||||
? Image.file(
|
||||
File(sake.displayData.imagePaths.first),
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) =>
|
||||
const Icon(LucideIcons.imageOff),
|
||||
)
|
||||
: const Icon(LucideIcons.image),
|
||||
),
|
||||
title: Text(sake.displayData.displayName),
|
||||
|
|
|
|||
|
|
@ -286,6 +286,7 @@ class _SakenowaRankingSectionState extends ConsumerState<SakenowaRankingSection>
|
|||
Image.file(
|
||||
File(item.userImagePath!),
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) => const SizedBox.shrink(),
|
||||
)
|
||||
else
|
||||
Container(
|
||||
|
|
|
|||
|
|
@ -1,9 +1,12 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import '../../providers/sake_list_provider.dart';
|
||||
import '../../providers/theme_provider.dart';
|
||||
import '../../services/backup_service.dart';
|
||||
import '../../theme/app_colors.dart';
|
||||
|
||||
class BackupSettingsSection extends StatefulWidget {
|
||||
class BackupSettingsSection extends ConsumerStatefulWidget {
|
||||
final String title;
|
||||
|
||||
const BackupSettingsSection({
|
||||
|
|
@ -12,12 +15,12 @@ class BackupSettingsSection extends StatefulWidget {
|
|||
});
|
||||
|
||||
@override
|
||||
State<BackupSettingsSection> createState() => _BackupSettingsSectionState();
|
||||
ConsumerState<BackupSettingsSection> createState() => _BackupSettingsSectionState();
|
||||
}
|
||||
|
||||
enum _BackupState { idle, signingIn, signingOut, backingUp, restoring }
|
||||
|
||||
class _BackupSettingsSectionState extends State<BackupSettingsSection> {
|
||||
class _BackupSettingsSectionState extends ConsumerState<BackupSettingsSection> {
|
||||
final BackupService _backupService = BackupService();
|
||||
_BackupState _state = _BackupState.idle;
|
||||
|
||||
|
|
@ -28,7 +31,11 @@ enum _BackupState { idle, signingIn, signingOut, backingUp, restoring }
|
|||
}
|
||||
|
||||
Future<void> _initBackupService() async {
|
||||
try {
|
||||
await _backupService.init();
|
||||
} catch (e) {
|
||||
debugPrint('[Backup] Init error (silent sign-in failed): $e');
|
||||
}
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
}
|
||||
|
|
@ -86,6 +93,7 @@ enum _BackupState { idle, signingIn, signingOut, backingUp, restoring }
|
|||
|
||||
Future<void> _createBackup() async {
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final appColors = Theme.of(context).extension<AppColors>()!;
|
||||
setState(() => _state = _BackupState.backingUp);
|
||||
final success = await _backupService.createBackup();
|
||||
if (mounted) {
|
||||
|
|
@ -93,11 +101,7 @@ enum _BackupState { idle, signingIn, signingOut, backingUp, restoring }
|
|||
messenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(success ? 'バックアップが完了しました' : 'バックアップに失敗しました'),
|
||||
// Snackbars can keep Green/Red for semantic clarity, or be neutral.
|
||||
// User asked to remove Green/Red icons from the UI, but feedback (Snackbar) usually stays semantic.
|
||||
// However, to be safe and "Washi", let's use Sumi (Black) for success?
|
||||
// Or just leave snackbars as they are ephemeral. The request was likely about the visible static UI.
|
||||
backgroundColor: success ? Colors.green : Colors.red,
|
||||
backgroundColor: success ? appColors.success : appColors.error,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -135,7 +139,7 @@ enum _BackupState { idle, signingIn, signingOut, backingUp, restoring }
|
|||
Future<void> _restoreBackup() async {
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final appColors = Theme.of(context).extension<AppColors>()!;
|
||||
// Note: hasBackup check is async
|
||||
|
||||
final hasBackup = await _backupService.hasBackupOnDrive();
|
||||
if (!hasBackup) {
|
||||
if (mounted) {
|
||||
|
|
@ -176,18 +180,77 @@ enum _BackupState { idle, signingIn, signingOut, backingUp, restoring }
|
|||
),
|
||||
);
|
||||
|
||||
if (confirmed == true && mounted) {
|
||||
if (confirmed != true || !mounted) return;
|
||||
await _executeRestore(forceSkipPreBackup: false);
|
||||
}
|
||||
|
||||
/// 実際の復元処理。PreRestoreBackupException を受けた場合はダイアログで続行確認する。
|
||||
Future<void> _executeRestore({required bool forceSkipPreBackup}) async {
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final appColors = Theme.of(context).extension<AppColors>()!;
|
||||
|
||||
setState(() => _state = _BackupState.restoring);
|
||||
final success = await _backupService.restoreBackup();
|
||||
|
||||
try {
|
||||
final success = forceSkipPreBackup
|
||||
? await _backupService.restoreBackupSkippingPreBackup()
|
||||
: await _backupService.restoreBackup();
|
||||
|
||||
if (mounted) {
|
||||
setState(() => _state = _BackupState.idle);
|
||||
if (success) {
|
||||
ref.invalidate(rawSakeListItemsProvider);
|
||||
ref.invalidate(sakeSortOrderProvider);
|
||||
ref.invalidate(userProfileProvider);
|
||||
}
|
||||
messenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(success ? '復元が完了しました' : '復元に失敗しました'),
|
||||
backgroundColor: success ? Colors.green : Colors.red,
|
||||
backgroundColor: success ? appColors.success : appColors.error,
|
||||
),
|
||||
);
|
||||
}
|
||||
} on PreRestoreBackupException {
|
||||
if (!mounted) return;
|
||||
setState(() => _state = _BackupState.idle);
|
||||
|
||||
// 事前バックアップ失敗 → ユーザーに警告して続行するか確認
|
||||
final continueAnyway = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Row(
|
||||
children: [
|
||||
Icon(LucideIcons.alertTriangle, color: appColors.error, size: 24),
|
||||
const SizedBox(width: 8),
|
||||
const Text('安全バックアップに失敗'),
|
||||
],
|
||||
),
|
||||
content: const Text(
|
||||
'復元前の安全コピー作成に失敗しました。\n'
|
||||
'このまま続行すると、現在のデータが失われた場合に\n'
|
||||
'元に戻せない可能性があります。\n\n'
|
||||
'続行しますか?',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('中断'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: appColors.error,
|
||||
foregroundColor: appColors.surfaceSubtle,
|
||||
),
|
||||
child: const Text('それでも続行'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (continueAnyway == true && mounted) {
|
||||
await _executeRestore(forceSkipPreBackup: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
52
pubspec.lock
52
pubspec.lock
|
|
@ -515,6 +515,54 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.0"
|
||||
flutter_secure_storage:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_secure_storage
|
||||
sha256: "9cad52d75ebc511adfae3d447d5d13da15a55a92c9410e50f67335b6d21d16ea"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "9.2.4"
|
||||
flutter_secure_storage_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_linux
|
||||
sha256: be76c1d24a97d0b98f8b54bce6b481a380a6590df992d0098f868ad54dc8f688
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.3"
|
||||
flutter_secure_storage_macos:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_macos
|
||||
sha256: "6c0a2795a2d1de26ae202a0d78527d163f4acbb11cde4c75c670f3a0fc064247"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.3"
|
||||
flutter_secure_storage_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_platform_interface
|
||||
sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.2"
|
||||
flutter_secure_storage_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_web
|
||||
sha256: f4ebff989b4f07b2656fb16b47852c0aab9fed9b4ec1c70103368337bc1886a9
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.1"
|
||||
flutter_secure_storage_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_windows
|
||||
sha256: b20b07cb5ed4ed74fc567b78a72936203f587eba460af1df11281c9326cd3709
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.2"
|
||||
flutter_speed_dial:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
|
@ -801,10 +849,10 @@ packages:
|
|||
dependency: transitive
|
||||
description:
|
||||
name: js
|
||||
sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc"
|
||||
sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.2"
|
||||
version: "0.6.7"
|
||||
json_annotation:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
|
|||
11
pubspec.yaml
11
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.38+45
|
||||
version: 1.0.49+56
|
||||
|
||||
environment:
|
||||
sdk: ^3.10.1
|
||||
|
|
@ -35,8 +35,8 @@ dependencies:
|
|||
# Use with the CupertinoIcons class for iOS style icons.
|
||||
cupertino_icons: ^1.0.8
|
||||
google_fonts: ^6.3.3
|
||||
flutter_riverpod:
|
||||
riverpod_annotation:
|
||||
flutter_riverpod: ^3.1.0
|
||||
riverpod_annotation: 3.0.0-dev.3
|
||||
hive: ^2.2.3
|
||||
hive_flutter: ^1.1.0
|
||||
google_generative_ai: ^0.4.7
|
||||
|
|
@ -46,7 +46,7 @@ dependencies:
|
|||
device_info_plus: ^10.1.0
|
||||
http: ^1.2.0
|
||||
crypto: ^3.0.3 # SHA-256 ハッシュ計算用(キャッシュ)
|
||||
lucide_icons: ^0.257.0
|
||||
lucide_icons: 0.257.0
|
||||
reorderable_grid_view: ^2.2.5
|
||||
camera: ^0.11.3
|
||||
path_provider: ^2.1.5
|
||||
|
|
@ -61,6 +61,7 @@ dependencies:
|
|||
package_info_plus: ^8.1.2
|
||||
gal: ^2.3.0
|
||||
shared_preferences: ^2.5.4
|
||||
flutter_secure_storage: ^9.2.2
|
||||
|
||||
# Phase 9: Google Drive Backup
|
||||
googleapis: ^13.2.0
|
||||
|
|
@ -85,7 +86,7 @@ dev_dependencies:
|
|||
flutter_lints: ^6.0.0
|
||||
build_runner:
|
||||
hive_generator:
|
||||
riverpod_generator:
|
||||
riverpod_generator: 3.0.0-dev.11
|
||||
flutter_launcher_icons: ^0.13.1
|
||||
flutter_native_splash: ^2.4.4
|
||||
|
||||
|
|
|
|||
|
|
@ -137,6 +137,57 @@ void main() {
|
|||
});
|
||||
});
|
||||
|
||||
group('SakeItem - ensureMigrated', () {
|
||||
test('レガシーフィールドからdisplayDataを構築する', () {
|
||||
final sake = SakeItem(
|
||||
id: 'legacy-001',
|
||||
legacyName: '出羽桜',
|
||||
legacyBrand: '出羽桜酒造',
|
||||
legacyPrefecture: '山形県',
|
||||
legacyCatchCopy: '花と夢と',
|
||||
legacyImagePaths: ['/path/legacy.jpg'],
|
||||
);
|
||||
|
||||
// displayData未設定の状態でgetterがlegacyから返す
|
||||
expect(sake.displayData.name, '出羽桜');
|
||||
expect(sake.displayData.brewery, '出羽桜酒造');
|
||||
expect(sake.displayData.prefecture, '山形県');
|
||||
|
||||
// ensureMigratedで新構造に昇格
|
||||
final migrated = sake.ensureMigrated();
|
||||
expect(migrated, true); // 移行が実行された
|
||||
|
||||
// 2回目はfalseを返す(既に移行済み)
|
||||
final again = sake.ensureMigrated();
|
||||
expect(again, false);
|
||||
});
|
||||
|
||||
test('displayDataが既に設定されている場合はfalseを返す', () {
|
||||
final sake = SakeItem(
|
||||
id: 'new-001',
|
||||
displayData: DisplayData(
|
||||
name: '新政',
|
||||
brewery: '新政酒造',
|
||||
prefecture: '秋田県',
|
||||
imagePaths: [],
|
||||
),
|
||||
);
|
||||
|
||||
final migrated = sake.ensureMigrated();
|
||||
expect(migrated, false);
|
||||
});
|
||||
|
||||
test('legacyNameがnullの場合はUnknownにフォールバックする', () {
|
||||
final sake = SakeItem(id: 'legacy-002');
|
||||
|
||||
sake.ensureMigrated();
|
||||
|
||||
expect(sake.displayData.name, 'Unknown');
|
||||
expect(sake.displayData.brewery, 'Unknown');
|
||||
expect(sake.displayData.prefecture, 'Unknown');
|
||||
});
|
||||
});
|
||||
|
||||
group('SakeItem - HiddenSpecs / TasteStats', () {
|
||||
test('should return SakeTasteStats from tasteStats map', () {
|
||||
final sake = SakeItem(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,158 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:ponshu_room_lite/services/gemini_service.dart';
|
||||
|
||||
void main() {
|
||||
group('SakeAnalysisResult.fromJson', () {
|
||||
test('正常なJSONから全フィールドを正しくパースする', () {
|
||||
final json = {
|
||||
'name': '獺祭',
|
||||
'brand': '旭酒造',
|
||||
'prefecture': '山口県',
|
||||
'type': '純米大吟醸',
|
||||
'description': 'フルーティーで華やかな香り',
|
||||
'catchCopy': '磨きその先へ',
|
||||
'confidenceScore': 92,
|
||||
'flavorTags': ['フルーティー', '辛口'],
|
||||
'tasteStats': {'aroma': 5, 'sweetness': 2, 'acidity': 3, 'bitterness': 2, 'body': 3},
|
||||
'alcoholContent': 16.0,
|
||||
'polishingRatio': 23,
|
||||
'sakeMeterValue': 3.0,
|
||||
'riceVariety': '山田錦',
|
||||
'yeast': 'きょうかい9号',
|
||||
'manufacturingYearMonth': '2024.01',
|
||||
};
|
||||
|
||||
final result = SakeAnalysisResult.fromJson(json);
|
||||
|
||||
expect(result.name, '獺祭');
|
||||
expect(result.brand, '旭酒造');
|
||||
expect(result.prefecture, '山口県');
|
||||
expect(result.type, '純米大吟醸');
|
||||
expect(result.confidenceScore, 92);
|
||||
expect(result.flavorTags, ['フルーティー', '辛口']);
|
||||
expect(result.alcoholContent, 16.0);
|
||||
expect(result.polishingRatio, 23);
|
||||
expect(result.riceVariety, '山田錦');
|
||||
expect(result.manufacturingYearMonth, '2024.01');
|
||||
expect(result.isFromCache, false);
|
||||
});
|
||||
|
||||
test('フィールドが全てnullの場合にデフォルト値が設定される', () {
|
||||
final result = SakeAnalysisResult.fromJson({});
|
||||
|
||||
expect(result.name, isNull);
|
||||
expect(result.brand, isNull);
|
||||
expect(result.prefecture, isNull);
|
||||
expect(result.flavorTags, isEmpty);
|
||||
expect(result.isFromCache, false);
|
||||
});
|
||||
|
||||
test('tasteStats: 範囲外の値(0, 6)が1〜5にクランプされる', () {
|
||||
final json = {
|
||||
'tasteStats': {
|
||||
'aroma': 0,
|
||||
'sweetness': 6,
|
||||
'acidity': -1,
|
||||
'bitterness': 10,
|
||||
'body': 3,
|
||||
},
|
||||
};
|
||||
|
||||
final result = SakeAnalysisResult.fromJson(json);
|
||||
|
||||
expect(result.tasteStats['aroma'], 1);
|
||||
expect(result.tasteStats['sweetness'], 5);
|
||||
expect(result.tasteStats['acidity'], 1);
|
||||
expect(result.tasteStats['bitterness'], 5);
|
||||
expect(result.tasteStats['body'], 3);
|
||||
});
|
||||
|
||||
test('tasteStats: 一部キーが欠損していると3で補完される', () {
|
||||
final json = {
|
||||
'tasteStats': {
|
||||
'aroma': 5,
|
||||
// sweetness, acidity, bitterness, body が欠損
|
||||
},
|
||||
};
|
||||
|
||||
final result = SakeAnalysisResult.fromJson(json);
|
||||
|
||||
expect(result.tasteStats['aroma'], 5);
|
||||
expect(result.tasteStats['sweetness'], 3);
|
||||
expect(result.tasteStats['acidity'], 3);
|
||||
expect(result.tasteStats['bitterness'], 3);
|
||||
expect(result.tasteStats['body'], 3);
|
||||
});
|
||||
|
||||
test('tasteStats: nullまたは不正な型の場合は全キーが3になる', () {
|
||||
final json = {'tasteStats': null};
|
||||
final result = SakeAnalysisResult.fromJson(json);
|
||||
|
||||
expect(result.tasteStats['aroma'], 3);
|
||||
expect(result.tasteStats['sweetness'], 3);
|
||||
expect(result.tasteStats['body'], 3);
|
||||
});
|
||||
|
||||
test('alcoholContent: intで渡された場合もdoubleとして取得できる', () {
|
||||
final json = {'alcoholContent': 15};
|
||||
final result = SakeAnalysisResult.fromJson(json);
|
||||
expect(result.alcoholContent, 15.0);
|
||||
});
|
||||
|
||||
test('flavorTags: nullの場合は空リストになる', () {
|
||||
final json = {'flavorTags': null};
|
||||
final result = SakeAnalysisResult.fromJson(json);
|
||||
expect(result.flavorTags, isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
group('SakeAnalysisResult.asCached', () {
|
||||
test('asCached()はisFromCache=trueを返す', () {
|
||||
final original = SakeAnalysisResult(name: '獺祭', brand: '旭酒造');
|
||||
final cached = original.asCached();
|
||||
|
||||
expect(cached.isFromCache, true);
|
||||
expect(cached.name, '獺祭');
|
||||
expect(cached.brand, '旭酒造');
|
||||
});
|
||||
|
||||
test('元のインスタンスはisFromCache=falseを維持する', () {
|
||||
final original = SakeAnalysisResult(name: '久保田');
|
||||
original.asCached();
|
||||
expect(original.isFromCache, false);
|
||||
});
|
||||
});
|
||||
|
||||
group('SakeAnalysisResult.toJson / fromJson 往復', () {
|
||||
test('toJson → fromJson で値が保持される', () {
|
||||
final original = SakeAnalysisResult(
|
||||
name: '八海山',
|
||||
brand: '八海醸造',
|
||||
prefecture: '新潟県',
|
||||
type: '特別本醸造',
|
||||
confidenceScore: 80,
|
||||
flavorTags: ['辛口', 'すっきり'],
|
||||
tasteStats: {'aroma': 2, 'sweetness': 2, 'acidity': 3, 'bitterness': 3, 'body': 3},
|
||||
alcoholContent: 15.5,
|
||||
polishingRatio: 55,
|
||||
);
|
||||
|
||||
final json = original.toJson();
|
||||
final restored = SakeAnalysisResult.fromJson(json);
|
||||
|
||||
expect(restored.name, original.name);
|
||||
expect(restored.brand, original.brand);
|
||||
expect(restored.prefecture, original.prefecture);
|
||||
expect(restored.confidenceScore, original.confidenceScore);
|
||||
expect(restored.flavorTags, original.flavorTags);
|
||||
expect(restored.alcoholContent, original.alcoholContent);
|
||||
expect(restored.polishingRatio, original.polishingRatio);
|
||||
});
|
||||
|
||||
test('toJson に isFromCache は含まれない', () {
|
||||
final result = SakeAnalysisResult(name: 'テスト').asCached();
|
||||
final json = result.toJson();
|
||||
expect(json.containsKey('isFromCache'), false);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -1,20 +1,20 @@
|
|||
{
|
||||
"date": "2026-04-16",
|
||||
"name": "Ponshu Room 1.0.37 (2026-04-16)",
|
||||
"version": "v1.0.37",
|
||||
{
|
||||
"version": "v1.0.49",
|
||||
"name": "Ponshu Room 1.0.49 (2026-04-23)",
|
||||
"date": "2026-04-23",
|
||||
"apks": {
|
||||
"eiji": {
|
||||
"lite": {
|
||||
"url": "https://posimai-lab.tail72e846.ts.net/mai/ponshu-room-lite/releases/download/v1.0.37/ponshu_room_consumer_eiji.apk",
|
||||
"size_mb": 89.1,
|
||||
"filename": "ponshu_room_consumer_eiji.apk"
|
||||
}
|
||||
},
|
||||
"maita": {
|
||||
"lite": {
|
||||
"url": "https://posimai-lab.tail72e846.ts.net/mai/ponshu-room-lite/releases/download/v1.0.37/ponshu_room_consumer_maita.apk",
|
||||
"size_mb": 89.1,
|
||||
"filename": "ponshu_room_consumer_maita.apk"
|
||||
"filename": "ponshu_room_consumer_maita.apk",
|
||||
"url": "https://posimai-lab.tail72e846.ts.net/mai/ponshu-room-lite/releases/download/v1.0.49/ponshu_room_consumer_maita.apk",
|
||||
"size_mb": 91
|
||||
}
|
||||
},
|
||||
"eiji": {
|
||||
"lite": {
|
||||
"filename": "ponshu_room_consumer_eiji.apk",
|
||||
"url": "https://posimai-lab.tail72e846.ts.net/mai/ponshu-room-lite/releases/download/v1.0.49/ponshu_room_consumer_eiji.apk",
|
||||
"size_mb": 91
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@
|
|||
<!-- Download Section -->
|
||||
<section class="download">
|
||||
<div class="version-cards">
|
||||
<!-- Lite Version -->
|
||||
<!-- Lite Version (Eiji) -->
|
||||
<a href="/releases/ponshu-room-lite.apk" class="version-card" download>
|
||||
<div class="card-header">
|
||||
<span class="version-name">Lite</span>
|
||||
|
|
@ -62,21 +62,21 @@
|
|||
<path d="M12 3v12m0 0l-4-4m4 4l4-4M5 17v2a2 2 0 002 2h10a2 2 0 002-2v-2" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="file-size">88 MB</span>
|
||||
<span class="file-size">90 MB</span>
|
||||
</a>
|
||||
|
||||
<!-- Pro Version -->
|
||||
<a href="/releases/ponshu-room-pro.apk" class="version-card pro" download>
|
||||
<!-- Maita Version -->
|
||||
<a href="/releases/ponshu-room-maita.apk" class="version-card" download>
|
||||
<div class="card-header">
|
||||
<span class="version-name">Pro</span>
|
||||
<span class="version-badge pro-badge">Pro</span>
|
||||
<span class="version-name">Maita</span>
|
||||
<span class="version-badge free">Dev</span>
|
||||
</div>
|
||||
<div class="card-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<path d="M12 3v12m0 0l-4-4m4 4l4-4M5 17v2a2 2 0 002 2h10a2 2 0 002-2v-2" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="file-size">89 MB</span>
|
||||
<span class="file-size">90 MB</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
|
|
@ -116,7 +116,7 @@
|
|||
|
||||
<!-- Footer -->
|
||||
<footer>
|
||||
<p>v1.0.12</p>
|
||||
<p>v1.0.44</p>
|
||||
</footer>
|
||||
</div>
|
||||
</body>
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
|
|
@ -8,6 +8,7 @@
|
|||
|
||||
#include <connectivity_plus/connectivity_plus_windows_plugin.h>
|
||||
#include <file_selector_windows/file_selector_windows.h>
|
||||
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
|
||||
#include <gal/gal_plugin_c_api.h>
|
||||
#include <printing/printing_plugin.h>
|
||||
#include <share_plus/share_plus_windows_plugin_c_api.h>
|
||||
|
|
@ -18,6 +19,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
|
|||
registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin"));
|
||||
FileSelectorWindowsRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("FileSelectorWindows"));
|
||||
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
|
||||
GalPluginCApiRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("GalPluginCApi"));
|
||||
PrintingPluginRegisterWithRegistrar(
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
connectivity_plus
|
||||
file_selector_windows
|
||||
flutter_secure_storage_windows
|
||||
gal
|
||||
printing
|
||||
share_plus
|
||||
|
|
|
|||
Loading…
Reference in New Issue