Compare commits
No commits in common. "main" and "v1.0.47" have entirely different histories.
|
|
@ -124,27 +124,18 @@ mixin CameraAnalysisMixin<T extends ConsumerStatefulWidget> on ConsumerState<T>
|
||||||
|
|
||||||
// オンライン時: 通常の解析フロー
|
// オンライン時: 通常の解析フロー
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
final stageNotifier = ValueNotifier<int>(1);
|
|
||||||
var stageNotifierDisposed = false;
|
|
||||||
|
|
||||||
// ignore: use_build_context_synchronously
|
// ignore: use_build_context_synchronously
|
||||||
// 直前の mounted チェックにより BuildContext の有効性は保証されている
|
// 直前の mounted チェックにより BuildContext の有効性は保証されている
|
||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
barrierDismissible: false,
|
barrierDismissible: false,
|
||||||
builder: (context) => AnalyzingDialog(stageNotifier: stageNotifier),
|
builder: (context) => const AnalyzingDialog(),
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
debugPrint('Starting Gemini 2-stage analysis for ${capturedImages.length} images');
|
debugPrint('Starting Gemini Vision Direct Analysis for ${capturedImages.length} images');
|
||||||
final geminiService = ref.read(geminiServiceProvider);
|
final geminiService = ref.read(geminiServiceProvider);
|
||||||
final result = await geminiService.analyzeSakeLabel(
|
final result = await geminiService.analyzeSakeLabel(capturedImages);
|
||||||
capturedImages,
|
|
||||||
onStep1Complete: () {
|
|
||||||
if (!stageNotifierDisposed) stageNotifier.value = 2;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// Create SakeItem (Schema v2.0)
|
// Create SakeItem (Schema v2.0)
|
||||||
final sakeItem = SakeItem(
|
final sakeItem = SakeItem(
|
||||||
|
|
@ -381,38 +372,17 @@ mixin CameraAnalysisMixin<T extends ConsumerStatefulWidget> on ConsumerState<T>
|
||||||
}
|
}
|
||||||
|
|
||||||
debugPrint('Analysis error: $e');
|
debugPrint('Analysis error: $e');
|
||||||
final errDetail = _extractErrorCode(e.toString());
|
|
||||||
messenger.showSnackBar(
|
messenger.showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text('解析に失敗しました。時間をおいて再試行してください。$errDetail'),
|
content: const Text('解析に失敗しました。時間をおいて再試行してください。'),
|
||||||
duration: const Duration(seconds: 5),
|
duration: const Duration(seconds: 5),
|
||||||
backgroundColor: appColors.error,
|
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 '';
|
|
||||||
}
|
|
||||||
|
|
||||||
/// さけのわ自動マッチング処理
|
/// さけのわ自動マッチング処理
|
||||||
///
|
///
|
||||||
/// 登録後にバックグラウンドで実行。
|
/// 登録後にバックグラウンドで実行。
|
||||||
|
|
|
||||||
|
|
@ -358,15 +358,6 @@ class _PendingAnalysisScreenState extends ConsumerState<PendingAnalysisScreen> {
|
||||||
width: 60,
|
width: 60,
|
||||||
height: 60,
|
height: 60,
|
||||||
fit: BoxFit.cover,
|
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(
|
: Container(
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:lucide_icons/lucide_icons.dart';
|
import 'package:lucide_icons/lucide_icons.dart';
|
||||||
import '../providers/theme_provider.dart';
|
import '../providers/theme_provider.dart';
|
||||||
import '../theme/app_colors.dart';
|
|
||||||
import '../widgets/settings/display_settings_section.dart';
|
import '../widgets/settings/display_settings_section.dart';
|
||||||
import '../widgets/settings/other_settings_section.dart';
|
import '../widgets/settings/other_settings_section.dart';
|
||||||
import '../widgets/settings/backup_settings_section.dart';
|
import '../widgets/settings/backup_settings_section.dart';
|
||||||
|
|
@ -23,7 +22,7 @@ class _ShopSettingsScreenState extends ConsumerState<ShopSettingsScreen> {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final userProfile = ref.watch(userProfileProvider);
|
final userProfile = ref.watch(userProfileProvider);
|
||||||
final appColors = Theme.of(context).extension<AppColors>()!;
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
|
|
@ -36,9 +35,9 @@ class _ShopSettingsScreenState extends ConsumerState<ShopSettingsScreen> {
|
||||||
// Business Config Section
|
// Business Config Section
|
||||||
_buildSectionHeader(context, '価格設定', LucideIcons.briefcase),
|
_buildSectionHeader(context, '価格設定', LucideIcons.briefcase),
|
||||||
Card(
|
Card(
|
||||||
color: appColors.surfaceElevated,
|
color: isDark ? const Color(0xFF1E1E1E) : null,
|
||||||
child: ListTile(
|
child: ListTile(
|
||||||
leading: Icon(LucideIcons.percent, color: appColors.iconAccent),
|
leading: Icon(LucideIcons.percent, color: isDark ? Colors.orange[300] : Theme.of(context).primaryColor),
|
||||||
title: const Text('基本掛率'),
|
title: const Text('基本掛率'),
|
||||||
trailing: Row(
|
trailing: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
|
@ -46,15 +45,15 @@ class _ShopSettingsScreenState extends ConsumerState<ShopSettingsScreen> {
|
||||||
Text('×', style: TextStyle(
|
Text('×', style: TextStyle(
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
color: appColors.textSecondary,
|
color: isDark ? Colors.grey[400] : Colors.grey[600],
|
||||||
)),
|
)),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: appColors.surfaceSubtle,
|
color: isDark ? Colors.grey[800] : Colors.grey[100],
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
border: Border.all(color: appColors.divider),
|
border: Border.all(color: isDark ? Colors.grey[700]! : Colors.grey[300]!),
|
||||||
),
|
),
|
||||||
child: DropdownButton<double>(
|
child: DropdownButton<double>(
|
||||||
value: userProfile.defaultMarkup,
|
value: userProfile.defaultMarkup,
|
||||||
|
|
@ -97,18 +96,18 @@ class _ShopSettingsScreenState extends ConsumerState<ShopSettingsScreen> {
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildSectionHeader(BuildContext context, String title, IconData icon) {
|
Widget _buildSectionHeader(BuildContext context, String title, IconData icon) {
|
||||||
final appColors = Theme.of(context).extension<AppColors>()!;
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 4),
|
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 4),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(icon, size: 20, color: appColors.iconAccent),
|
Icon(icon, size: 20, color: isDark ? Colors.orange[300] : Theme.of(context).primaryColor),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Text(
|
Text(
|
||||||
title,
|
title,
|
||||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
color: appColors.textPrimary,
|
color: isDark ? Colors.grey[300] : Theme.of(context).primaryColor,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -11,15 +11,6 @@ import 'package:path_provider/path_provider.dart';
|
||||||
import 'package:path/path.dart' as path;
|
import 'package:path/path.dart' as path;
|
||||||
import '../models/sake_item.dart';
|
import '../models/sake_item.dart';
|
||||||
|
|
||||||
/// 復元前の安全バックアップ作成に失敗したことを示す例外。
|
|
||||||
/// UI 側でユーザーに中断/続行を選ばせるために使用する。
|
|
||||||
class PreRestoreBackupException implements Exception {
|
|
||||||
const PreRestoreBackupException();
|
|
||||||
|
|
||||||
@override
|
|
||||||
String toString() => 'PreRestoreBackupException: pre-restore safety backup failed';
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Google Driveへのバックアップ・復元を管理するサービス
|
/// Google Driveへのバックアップ・復元を管理するサービス
|
||||||
///
|
///
|
||||||
/// 【主な機能】
|
/// 【主な機能】
|
||||||
|
|
@ -334,11 +325,8 @@ class BackupService {
|
||||||
|
|
||||||
final driveApi = drive.DriveApi(authClient);
|
final driveApi = drive.DriveApi(authClient);
|
||||||
|
|
||||||
// 3. 現在のデータを退避(失敗したら呼び出し元に通知して中断させる)
|
// 3. 現在のデータを退避
|
||||||
final preBackupOk = await _createPreRestoreBackup();
|
await _createPreRestoreBackup();
|
||||||
if (!preBackupOk) {
|
|
||||||
throw const PreRestoreBackupException();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Google Driveからダウンロード
|
// 4. Google Driveからダウンロード
|
||||||
final zipFile = await _downloadFromDrive(driveApi);
|
final zipFile = await _downloadFromDrive(driveApi);
|
||||||
|
|
@ -354,55 +342,26 @@ class BackupService {
|
||||||
await zipFile.delete();
|
await zipFile.delete();
|
||||||
|
|
||||||
return success;
|
return success;
|
||||||
} on PreRestoreBackupException {
|
|
||||||
rethrow;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
debugPrint('[RESTORE] Restore error: $error');
|
debugPrint('[RESTORE] Restore error: $error');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 復元前に現在のデータを退避する。成功したら true、失敗したら false を返す。
|
/// 復元前に現在のデータを退避
|
||||||
Future<bool> _createPreRestoreBackup() async {
|
Future<void> _createPreRestoreBackup() async {
|
||||||
try {
|
try {
|
||||||
final tempDir = await getTemporaryDirectory();
|
final tempDir = await getTemporaryDirectory();
|
||||||
final backupPath = path.join(tempDir.path, 'pre_restore_backup.zip');
|
final backupPath = path.join(tempDir.path, 'pre_restore_backup.zip');
|
||||||
|
|
||||||
final zipFile = await _createBackupZip();
|
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.copy(backupPath);
|
||||||
await zipFile.delete();
|
await zipFile.delete();
|
||||||
debugPrint('[RESTORE] Pre-restore backup saved: $backupPath');
|
debugPrint('[RESTORE] Pre-restore backup saved: $backupPath');
|
||||||
return true;
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
debugPrint('[RESTORE] Pre-restore backup error: $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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -434,14 +393,7 @@ class BackupService {
|
||||||
|
|
||||||
// 3. ストリームをファイルに書き込み
|
// 3. ストリームをファイルに書き込み
|
||||||
final sink = downloadFile.openWrite();
|
final sink = downloadFile.openWrite();
|
||||||
await media.stream.pipe(sink).timeout(
|
await media.stream.pipe(sink);
|
||||||
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');
|
debugPrint('[RESTORE] Download complete: $downloadPath');
|
||||||
return downloadFile;
|
return downloadFile;
|
||||||
|
|
@ -532,7 +484,7 @@ class BackupService {
|
||||||
isUserEdited: data['userData']['isUserEdited'] as bool,
|
isUserEdited: data['userData']['isUserEdited'] as bool,
|
||||||
price: data['userData']['price'] as int?,
|
price: data['userData']['price'] as int?,
|
||||||
costPrice: data['userData']['costPrice'] as int?,
|
costPrice: data['userData']['costPrice'] as int?,
|
||||||
markup: (data['userData']['markup'] as num?)?.toDouble() ?? 3.0,
|
markup: (data['userData']['markup'] as num).toDouble(),
|
||||||
priceVariants: data['userData']['priceVariants'] != null
|
priceVariants: data['userData']['priceVariants'] != null
|
||||||
? Map<String, int>.from(data['userData']['priceVariants'] as Map)
|
? Map<String, int>.from(data['userData']['priceVariants'] as Map)
|
||||||
: null,
|
: null,
|
||||||
|
|
|
||||||
|
|
@ -13,280 +13,16 @@ class GeminiService {
|
||||||
// AI Proxy Server Configuration
|
// AI Proxy Server Configuration
|
||||||
static final String _proxyUrl = Secrets.aiProxyAnalyzeUrl;
|
static final String _proxyUrl = Secrets.aiProxyAnalyzeUrl;
|
||||||
|
|
||||||
|
// レート制限対策? Proxy側で管理されているが、念のためクライアント側でも連打防止
|
||||||
static DateTime? _lastApiCallTime;
|
static DateTime? _lastApiCallTime;
|
||||||
static const Duration _minApiInterval = Duration(seconds: 2);
|
static const Duration _minApiInterval = Duration(seconds: 2);
|
||||||
|
|
||||||
GeminiService();
|
GeminiService();
|
||||||
|
|
||||||
// ============================================================
|
/// 画像リストから日本酒ラベルを解析
|
||||||
// Public API
|
Future<SakeAnalysisResult> analyzeSakeLabel(List<String> imagePaths, {bool forceRefresh = false}) async {
|
||||||
// ============================================================
|
// クライアント側プロンプトでスキーマの一貫性を保証
|
||||||
|
const prompt = '''
|
||||||
/// 画像リストから日本酒ラベルを解析(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を返してください。
|
添付画像から情報を読み取り、以下のJSONを返してください。
|
||||||
|
|
||||||
|
|
@ -350,15 +86,93 @@ name・brand を出力する直前に以下を確認してください:
|
||||||
}
|
}
|
||||||
''';
|
''';
|
||||||
|
|
||||||
// ============================================================
|
return _callProxyApi(
|
||||||
// プロキシ経由APIコール(iOSビルド用 / USE_PROXY=true 時)
|
imagePaths: imagePaths,
|
||||||
// ============================================================
|
customPrompt: prompt, // Override server default
|
||||||
|
forceRefresh: forceRefresh,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 再解析専用メソッド: 前回の結果を「疑わしい」として渡し、モデルに再考させる
|
||||||
|
///
|
||||||
|
/// 通常の analyzeSakeLabel と異なる点:
|
||||||
|
/// - 前回の name/brand を明示的に伝え「本当にこれが正しいか?」と問い直す
|
||||||
|
/// - temperature=0.3 で確定論的でなくする(同じ入力でも違う出力の余地を作る)
|
||||||
|
/// - これにより 東魁→東魁盛 のような hallucination が再解析でも繰り返されにくくなる
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 共通実装: ProxyへのAPIコール
|
||||||
Future<SakeAnalysisResult> _callProxyApi({
|
Future<SakeAnalysisResult> _callProxyApi({
|
||||||
required List<String> imagePaths,
|
required List<String> imagePaths,
|
||||||
String? customPrompt,
|
String? customPrompt,
|
||||||
bool forceRefresh = false,
|
bool forceRefresh = false,
|
||||||
}) async {
|
}) 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 のときのみ)
|
// 1. キャッシュチェック(forceRefresh=false のときのみ)
|
||||||
if (!forceRefresh && imagePaths.isNotEmpty) {
|
if (!forceRefresh && imagePaths.isNotEmpty) {
|
||||||
final imageHash = await AnalysisCacheService.computeCombinedHash(imagePaths);
|
final imageHash = await AnalysisCacheService.computeCombinedHash(imagePaths);
|
||||||
|
|
@ -379,20 +193,21 @@ name・brand を出力する直前に以下を確認してください:
|
||||||
}
|
}
|
||||||
_lastApiCallTime = DateTime.now();
|
_lastApiCallTime = DateTime.now();
|
||||||
|
|
||||||
// 3. 画像をBase64変換(撮影時に圧縮済み)
|
// 2. 画像をBase64変換(撮影時に圧縮済み)
|
||||||
List<String> base64Images = [];
|
List<String> base64Images = [];
|
||||||
for (final path in imagePaths) {
|
for (final path in imagePaths) {
|
||||||
|
// Read already-compressed images directly (compressed at capture time)
|
||||||
final bytes = await File(path).readAsBytes();
|
final bytes = await File(path).readAsBytes();
|
||||||
final base64String = base64Encode(bytes);
|
final base64String = base64Encode(bytes);
|
||||||
base64Images.add(base64String);
|
base64Images.add(base64String);
|
||||||
debugPrint('Encoded processed image: ${(bytes.length / 1024).toStringAsFixed(1)}KB');
|
debugPrint('Encoded processed image: ${(bytes.length / 1024).toStringAsFixed(1)}KB');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. デバイスID取得
|
// 3. デバイスID取得
|
||||||
final deviceId = await DeviceService.getDeviceId();
|
final deviceId = await DeviceService.getDeviceId();
|
||||||
if (kDebugMode) debugPrint('Device ID: $deviceId');
|
if (kDebugMode) debugPrint('Device ID: $deviceId');
|
||||||
|
|
||||||
// 5. リクエスト作成
|
// 4. リクエスト作成
|
||||||
final requestBody = jsonEncode({
|
final requestBody = jsonEncode({
|
||||||
"device_id": deviceId,
|
"device_id": deviceId,
|
||||||
"images": base64Images,
|
"images": base64Images,
|
||||||
|
|
@ -401,7 +216,7 @@ name・brand を出力する直前に以下を確認してください:
|
||||||
|
|
||||||
debugPrint('Calling Proxy: $_proxyUrl');
|
debugPrint('Calling Proxy: $_proxyUrl');
|
||||||
|
|
||||||
// 6. 送信(Bearer Token認証付き)
|
// 5. 送信(Bearer Token認証付き)
|
||||||
final headers = {
|
final headers = {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
if (Secrets.proxyAuthToken.isNotEmpty)
|
if (Secrets.proxyAuthToken.isNotEmpty)
|
||||||
|
|
@ -411,16 +226,18 @@ name・brand を出力する直前に以下を確認してください:
|
||||||
Uri.parse(_proxyUrl),
|
Uri.parse(_proxyUrl),
|
||||||
headers: headers,
|
headers: headers,
|
||||||
body: requestBody,
|
body: requestBody,
|
||||||
).timeout(const Duration(seconds: 60));
|
).timeout(const Duration(seconds: 60)); // 拡張: 60秒 (画像サイズ最適化済み)
|
||||||
|
|
||||||
// 7. レスポンス処理
|
// 6. レスポンス処理
|
||||||
if (response.statusCode == 200) {
|
if (response.statusCode == 200) {
|
||||||
|
// 成功時のレスポンス形式: { "success": true, "data": {...}, "usage": {...} }
|
||||||
final jsonResponse = jsonDecode(utf8.decode(response.bodyBytes));
|
final jsonResponse = jsonDecode(utf8.decode(response.bodyBytes));
|
||||||
|
|
||||||
if (jsonResponse['success'] == true) {
|
if (jsonResponse['success'] == true) {
|
||||||
final data = jsonResponse['data'];
|
final data = jsonResponse['data'];
|
||||||
if (data == null) throw Exception("サーバーからのデータが空です");
|
if (data == null) throw Exception("サーバーからのデータが空です");
|
||||||
|
|
||||||
|
// 使用状況ログ
|
||||||
if (jsonResponse['usage'] != null) {
|
if (jsonResponse['usage'] != null) {
|
||||||
final usage = jsonResponse['usage'];
|
final usage = jsonResponse['usage'];
|
||||||
debugPrint('API Usage: ${usage['today']}/${usage['limit']}');
|
debugPrint('API Usage: ${usage['today']}/${usage['limit']}');
|
||||||
|
|
@ -428,6 +245,9 @@ name・brand を出力する直前に以下を確認してください:
|
||||||
|
|
||||||
final result = SakeAnalysisResult.fromJson(data);
|
final result = SakeAnalysisResult.fromJson(data);
|
||||||
|
|
||||||
|
// tasteStats の補完・バリデーションは SakeAnalysisResult.fromJson 内で実施済み
|
||||||
|
|
||||||
|
// キャッシュに保存(次回同一画像はAPI不使用)
|
||||||
if (imagePaths.isNotEmpty) {
|
if (imagePaths.isNotEmpty) {
|
||||||
final imageHash = await AnalysisCacheService.computeCombinedHash(imagePaths);
|
final imageHash = await AnalysisCacheService.computeCombinedHash(imagePaths);
|
||||||
await AnalysisCacheService.saveCache(imageHash, result);
|
await AnalysisCacheService.saveCache(imageHash, result);
|
||||||
|
|
@ -440,9 +260,11 @@ name・brand を出力する直前に以下を確認してください:
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
} else {
|
} else {
|
||||||
|
// Proxy側での論理エラー (レート制限超過など)
|
||||||
throw Exception(jsonResponse['error'] ?? '不明なエラーが発生しました');
|
throw Exception(jsonResponse['error'] ?? '不明なエラーが発生しました');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
// HTTPエラー
|
||||||
if (kDebugMode) {
|
if (kDebugMode) {
|
||||||
debugPrint('Proxy Error: ${response.statusCode} ${response.body}');
|
debugPrint('Proxy Error: ${response.statusCode} ${response.body}');
|
||||||
}
|
}
|
||||||
|
|
@ -451,6 +273,7 @@ name・brand を出力する直前に以下を確認してください:
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('Proxy Call Failed: $e');
|
debugPrint('Proxy Call Failed: $e');
|
||||||
|
// エラーメッセージを整形
|
||||||
final errorMsg = e.toString().toLowerCase();
|
final errorMsg = e.toString().toLowerCase();
|
||||||
if (errorMsg.contains('limit') || errorMsg.contains('上限')) {
|
if (errorMsg.contains('limit') || errorMsg.contains('上限')) {
|
||||||
throw Exception('本日のAI解析リクエスト上限に達しました。\n明日またお試しください。');
|
throw Exception('本日のAI解析リクエスト上限に達しました。\n明日またお試しください。');
|
||||||
|
|
@ -459,16 +282,10 @@ name・brand を出力する直前に以下を確認してください:
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
/// Direct Cloud API Implementation (No Proxy)
|
||||||
// 直接APIコール(consumer APK 用 / USE_PROXY=false 時)
|
Future<SakeAnalysisResult> _callDirectApi(List<String> imagePaths, String? customPrompt, {bool forceRefresh = false, double temperature = 0}) async {
|
||||||
// ============================================================
|
// 1. キャッシュチェック(同じ画像なら即座に返す)
|
||||||
|
// forceRefresh=trueの場合はキャッシュをスキップ
|
||||||
Future<SakeAnalysisResult> _callDirectApi(
|
|
||||||
List<String> imagePaths,
|
|
||||||
String? customPrompt, {
|
|
||||||
bool forceRefresh = false,
|
|
||||||
double temperature = 0,
|
|
||||||
}) async {
|
|
||||||
if (!forceRefresh && imagePaths.isNotEmpty) {
|
if (!forceRefresh && imagePaths.isNotEmpty) {
|
||||||
final imageHash = await AnalysisCacheService.computeCombinedHash(imagePaths);
|
final imageHash = await AnalysisCacheService.computeCombinedHash(imagePaths);
|
||||||
final cached = await AnalysisCacheService.getCached(imageHash);
|
final cached = await AnalysisCacheService.getCached(imageHash);
|
||||||
|
|
@ -478,22 +295,65 @@ name・brand を出力する直前に以下を確認してください:
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 2. API Key確認
|
||||||
final apiKey = Secrets.geminiApiKey;
|
final apiKey = Secrets.geminiApiKey;
|
||||||
if (apiKey.isEmpty) {
|
if (apiKey.isEmpty) {
|
||||||
throw Exception('Gemini API Key is missing. Please set GEMINI_API_KEY.');
|
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 primaryModel = 'gemini-2.5-flash';
|
||||||
const fallbackModel = 'gemini-2.5-flash-lite';
|
const fallbackModel = 'gemini-2.0-flash';
|
||||||
|
|
||||||
final promptText = customPrompt ?? _mainAnalysisPrompt;
|
// customPrompt は analyzeSakeLabel から常に渡される。null になるケースは通常存在しない。
|
||||||
|
final promptText = customPrompt ?? '''
|
||||||
|
あなたは日本酒ラベル解析の専門家です。
|
||||||
|
添付画像から情報を読み取り、以下のJSONを返してください。
|
||||||
|
|
||||||
|
## 【絶対ルール】name・brand・prefectureの読み取り(OCR厳守)
|
||||||
|
これら3フィールドは、ラベルに印刷されている文字だけを一字一句そのまま出力してください。
|
||||||
|
あなたが知っている銘柄知識でラベルの文字を補完・変換・拡張することは厳禁です。
|
||||||
|
|
||||||
|
【禁止例】「東魁」→「東魁盛」禁止 / 「男山」→「男山本醸造」禁止 / 「白鹿」→「白鹿本醸造」禁止
|
||||||
|
【一般原則】ラベルに N 文字しか見えない場合は N 文字のみ出力する(文字数を増やすことは禁止)
|
||||||
|
- 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)];
|
final contentParts = <Part>[TextPart(promptText)];
|
||||||
for (var path in imagePaths) {
|
for (var path in imagePaths) {
|
||||||
final bytes = await File(path).readAsBytes();
|
final bytes = await File(path).readAsBytes();
|
||||||
contentParts.add(DataPart('image/jpeg', bytes));
|
contentParts.add(DataPart('image/jpeg', bytes));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 503 時: リトライ(指数バックオフ)→ フォールバックモデル
|
||||||
const maxRetries = 3;
|
const maxRetries = 3;
|
||||||
final modelsToTry = [primaryModel, primaryModel, primaryModel, fallbackModel];
|
final modelsToTry = [primaryModel, primaryModel, primaryModel, fallbackModel];
|
||||||
|
|
||||||
|
|
@ -538,9 +398,11 @@ name・brand を出力する直前に以下を確認してください:
|
||||||
final jsonMap = jsonDecode(jsonString);
|
final jsonMap = jsonDecode(jsonString);
|
||||||
final result = SakeAnalysisResult.fromJson(jsonMap);
|
final result = SakeAnalysisResult.fromJson(jsonMap);
|
||||||
|
|
||||||
|
// 3. キャッシュに保存(次回は即座に返せる)
|
||||||
if (imagePaths.isNotEmpty) {
|
if (imagePaths.isNotEmpty) {
|
||||||
final imageHash = await AnalysisCacheService.computeCombinedHash(imagePaths);
|
final imageHash = await AnalysisCacheService.computeCombinedHash(imagePaths);
|
||||||
await AnalysisCacheService.saveCache(imageHash, result);
|
await AnalysisCacheService.saveCache(imageHash, result);
|
||||||
|
// 4. 銘柄名インデックスに登録(forceRefresh 時は誤認識結果を上書き)
|
||||||
await AnalysisCacheService.registerBrandIndex(
|
await AnalysisCacheService.registerBrandIndex(
|
||||||
result.name,
|
result.name,
|
||||||
imageHash,
|
imageHash,
|
||||||
|
|
@ -557,20 +419,22 @@ name・brand を出力する直前に以下を確認してください:
|
||||||
debugPrint('Direct API Error (attempt $attempt, model: $modelName): $e');
|
debugPrint('Direct API Error (attempt $attempt, model: $modelName): $e');
|
||||||
|
|
||||||
if (isLastAttempt || !is503) {
|
if (isLastAttempt || !is503) {
|
||||||
if (is503) throw const GeminiCongestionException();
|
// 最終試行 or 503以外のエラーはそのまま投げる
|
||||||
|
if (is503) {
|
||||||
|
throw const GeminiCongestionException();
|
||||||
|
}
|
||||||
throw Exception('AI解析エラー(Direct): $e');
|
throw Exception('AI解析エラー(Direct): $e');
|
||||||
}
|
}
|
||||||
|
// 503 → 次のリトライへ
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ここには到達しない
|
||||||
throw Exception('AI解析に失敗しました。再試行してください。');
|
throw Exception('AI解析に失敗しました。再試行してください。');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// Analysis Result Model
|
||||||
// Data Models
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
class SakeAnalysisResult {
|
class SakeAnalysisResult {
|
||||||
final String? name;
|
final String? name;
|
||||||
final String? brand;
|
final String? brand;
|
||||||
|
|
@ -582,6 +446,7 @@ class SakeAnalysisResult {
|
||||||
final List<String> flavorTags;
|
final List<String> flavorTags;
|
||||||
final Map<String, int> tasteStats;
|
final Map<String, int> tasteStats;
|
||||||
|
|
||||||
|
// New Fields
|
||||||
final double? alcoholContent;
|
final double? alcoholContent;
|
||||||
final int? polishingRatio;
|
final int? polishingRatio;
|
||||||
final double? sakeMeterValue;
|
final double? sakeMeterValue;
|
||||||
|
|
@ -590,6 +455,7 @@ class SakeAnalysisResult {
|
||||||
final String? manufacturingYearMonth;
|
final String? manufacturingYearMonth;
|
||||||
|
|
||||||
/// キャッシュから返された結果かどうか(EXP付与・新規登録の判定に使用)
|
/// キャッシュから返された結果かどうか(EXP付与・新規登録の判定に使用)
|
||||||
|
/// JSON には含まない(キャッシュ保存・復元時は常に false)
|
||||||
final bool isFromCache;
|
final bool isFromCache;
|
||||||
|
|
||||||
SakeAnalysisResult({
|
SakeAnalysisResult({
|
||||||
|
|
@ -611,6 +477,7 @@ class SakeAnalysisResult {
|
||||||
this.isFromCache = false,
|
this.isFromCache = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// キャッシュヒット用コピー
|
||||||
SakeAnalysisResult asCached() => SakeAnalysisResult(
|
SakeAnalysisResult asCached() => SakeAnalysisResult(
|
||||||
name: name, brand: brand, prefecture: prefecture, type: type,
|
name: name, brand: brand, prefecture: prefecture, type: type,
|
||||||
description: description, catchCopy: catchCopy, confidenceScore: confidenceScore,
|
description: description, catchCopy: catchCopy, confidenceScore: confidenceScore,
|
||||||
|
|
@ -622,6 +489,7 @@ class SakeAnalysisResult {
|
||||||
);
|
);
|
||||||
|
|
||||||
factory SakeAnalysisResult.fromJson(Map<String, dynamic> json) {
|
factory SakeAnalysisResult.fromJson(Map<String, dynamic> json) {
|
||||||
|
// tasteStats: 欠損キーを 3 で補完、範囲外(1〜5)をクランプ
|
||||||
const requiredStatKeys = ['aroma', 'sweetness', 'acidity', 'bitterness', 'body'];
|
const requiredStatKeys = ['aroma', 'sweetness', 'acidity', 'bitterness', 'body'];
|
||||||
Map<String, int> stats = {};
|
Map<String, int> stats = {};
|
||||||
if (json['tasteStats'] is Map) {
|
if (json['tasteStats'] is Map) {
|
||||||
|
|
@ -654,6 +522,7 @@ class SakeAnalysisResult {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// JSON形式に変換(キャッシュ保存用)
|
||||||
Map<String, dynamic> toJson() {
|
Map<String, dynamic> toJson() {
|
||||||
return {
|
return {
|
||||||
'name': name,
|
'name': name,
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,7 @@ import 'package:flutter/material.dart';
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
class AnalyzingDialog extends StatefulWidget {
|
class AnalyzingDialog extends StatefulWidget {
|
||||||
/// Stage 通知用 ValueNotifier。
|
const AnalyzingDialog({super.key});
|
||||||
/// null のとき(プロキシモード・キャッシュヒット後など)は Stage1 メッセージのみを表示する。
|
|
||||||
/// value が 2 に変わると Stage2 メッセージセットに切り替わる。
|
|
||||||
final ValueNotifier<int>? stageNotifier;
|
|
||||||
|
|
||||||
const AnalyzingDialog({super.key, this.stageNotifier});
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<AnalyzingDialog> createState() => _AnalyzingDialogState();
|
State<AnalyzingDialog> createState() => _AnalyzingDialogState();
|
||||||
|
|
@ -15,70 +10,32 @@ class AnalyzingDialog extends StatefulWidget {
|
||||||
|
|
||||||
class _AnalyzingDialogState extends State<AnalyzingDialog> {
|
class _AnalyzingDialogState extends State<AnalyzingDialog> {
|
||||||
int _messageIndex = 0;
|
int _messageIndex = 0;
|
||||||
Timer? _timer;
|
|
||||||
|
|
||||||
static const _stage1Messages = [
|
final List<String> _messages = [
|
||||||
'ラベルを読み取っています...',
|
'ラベルを読んでいます...',
|
||||||
'文字を一字一句確認中...',
|
'銘柄を確認しています...',
|
||||||
];
|
|
||||||
|
|
||||||
static const _stage2Messages = [
|
|
||||||
'この日本酒の個性を分析中...',
|
'この日本酒の個性を分析中...',
|
||||||
'フレーバーチャートを描画しています...',
|
'フレーバーチャートを描画しています...',
|
||||||
'素敵なキャッチコピーを考えています...',
|
'素敵なキャッチコピーを考えています...',
|
||||||
];
|
];
|
||||||
|
|
||||||
List<String> get _currentMessages =>
|
|
||||||
(_stage == 2) ? _stage2Messages : _stage1Messages;
|
|
||||||
|
|
||||||
int _stage = 1;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
widget.stageNotifier?.addListener(_onStageChanged);
|
|
||||||
_startMessageRotation();
|
_startMessageRotation();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onStageChanged() {
|
|
||||||
final newStage = widget.stageNotifier?.value ?? 1;
|
|
||||||
if (newStage != _stage) {
|
|
||||||
_timer?.cancel();
|
|
||||||
setState(() {
|
|
||||||
_stage = newStage;
|
|
||||||
_messageIndex = 0;
|
|
||||||
});
|
|
||||||
_startMessageRotation();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _startMessageRotation() {
|
void _startMessageRotation() {
|
||||||
_timer = Timer.periodic(const Duration(milliseconds: 1800), (timer) {
|
Future.delayed(const Duration(milliseconds: 1500), () {
|
||||||
if (!mounted) {
|
if (mounted && _messageIndex < _messages.length - 1) {
|
||||||
timer.cancel();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
final messages = _currentMessages;
|
|
||||||
if (_messageIndex < messages.length - 1) {
|
|
||||||
setState(() => _messageIndex++);
|
setState(() => _messageIndex++);
|
||||||
} else {
|
_startMessageRotation();
|
||||||
timer.cancel();
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_timer?.cancel();
|
|
||||||
widget.stageNotifier?.removeListener(_onStageChanged);
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final messages = _currentMessages;
|
|
||||||
final safeIndex = _messageIndex.clamp(0, messages.length - 1);
|
|
||||||
|
|
||||||
return Dialog(
|
return Dialog(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(24.0),
|
padding: const EdgeInsets.all(24.0),
|
||||||
|
|
@ -88,19 +45,10 @@ class _AnalyzingDialogState extends State<AnalyzingDialog> {
|
||||||
const CircularProgressIndicator(),
|
const CircularProgressIndicator(),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
Text(
|
Text(
|
||||||
messages[safeIndex],
|
_messages[_messageIndex],
|
||||||
style: Theme.of(context).textTheme.titleMedium,
|
style: Theme.of(context).textTheme.titleMedium,
|
||||||
textAlign: TextAlign.center,
|
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),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -272,7 +272,6 @@ class _SakeDetailSpecsState extends State<SakeDetailSpecs> {
|
||||||
_isEditing,
|
_isEditing,
|
||||||
suffixIcon: LucideIcons.calendar,
|
suffixIcon: LucideIcons.calendar,
|
||||||
onSuffixTap: () => _showDatePicker(context),
|
onSuffixTap: () => _showDatePicker(context),
|
||||||
helperText: '例: 2023-10',
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
@ -304,11 +303,10 @@ class _SakeDetailSpecsState extends State<SakeDetailSpecs> {
|
||||||
void _showDatePicker(BuildContext context) {
|
void _showDatePicker(BuildContext context) {
|
||||||
if (!_isEditing) return;
|
if (!_isEditing) return;
|
||||||
|
|
||||||
// Parse current value or use now (AI出力 "2023.10" とユーザー入力 "2023-10" の両形式に対応)
|
// Parse current value or use now
|
||||||
DateTime initialDate = DateTime.now();
|
DateTime initialDate = DateTime.now();
|
||||||
try {
|
try {
|
||||||
final normalized = _manufacturingController.text.replaceAll('.', '-');
|
final parts = _manufacturingController.text.split('-');
|
||||||
final parts = normalized.split('-');
|
|
||||||
if (parts.length >= 2) {
|
if (parts.length >= 2) {
|
||||||
final year = int.parse(parts[0]);
|
final year = int.parse(parts[0]);
|
||||||
final month = int.parse(parts[1]);
|
final month = int.parse(parts[1]);
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,9 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
||||||
import 'package:lucide_icons/lucide_icons.dart';
|
import 'package:lucide_icons/lucide_icons.dart';
|
||||||
import '../../providers/sake_list_provider.dart';
|
|
||||||
import '../../providers/theme_provider.dart';
|
|
||||||
import '../../services/backup_service.dart';
|
import '../../services/backup_service.dart';
|
||||||
import '../../theme/app_colors.dart';
|
import '../../theme/app_colors.dart';
|
||||||
|
|
||||||
class BackupSettingsSection extends ConsumerStatefulWidget {
|
class BackupSettingsSection extends StatefulWidget {
|
||||||
final String title;
|
final String title;
|
||||||
|
|
||||||
const BackupSettingsSection({
|
const BackupSettingsSection({
|
||||||
|
|
@ -15,12 +12,12 @@ class BackupSettingsSection extends ConsumerStatefulWidget {
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
ConsumerState<BackupSettingsSection> createState() => _BackupSettingsSectionState();
|
State<BackupSettingsSection> createState() => _BackupSettingsSectionState();
|
||||||
}
|
}
|
||||||
|
|
||||||
enum _BackupState { idle, signingIn, signingOut, backingUp, restoring }
|
enum _BackupState { idle, signingIn, signingOut, backingUp, restoring }
|
||||||
|
|
||||||
class _BackupSettingsSectionState extends ConsumerState<BackupSettingsSection> {
|
class _BackupSettingsSectionState extends State<BackupSettingsSection> {
|
||||||
final BackupService _backupService = BackupService();
|
final BackupService _backupService = BackupService();
|
||||||
_BackupState _state = _BackupState.idle;
|
_BackupState _state = _BackupState.idle;
|
||||||
|
|
||||||
|
|
@ -31,11 +28,7 @@ enum _BackupState { idle, signingIn, signingOut, backingUp, restoring }
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _initBackupService() async {
|
Future<void> _initBackupService() async {
|
||||||
try {
|
|
||||||
await _backupService.init();
|
await _backupService.init();
|
||||||
} catch (e) {
|
|
||||||
debugPrint('[Backup] Init error (silent sign-in failed): $e');
|
|
||||||
}
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {});
|
setState(() {});
|
||||||
}
|
}
|
||||||
|
|
@ -139,7 +132,7 @@ enum _BackupState { idle, signingIn, signingOut, backingUp, restoring }
|
||||||
Future<void> _restoreBackup() async {
|
Future<void> _restoreBackup() async {
|
||||||
final messenger = ScaffoldMessenger.of(context);
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
final appColors = Theme.of(context).extension<AppColors>()!;
|
final appColors = Theme.of(context).extension<AppColors>()!;
|
||||||
|
// Note: hasBackup check is async
|
||||||
final hasBackup = await _backupService.hasBackupOnDrive();
|
final hasBackup = await _backupService.hasBackupOnDrive();
|
||||||
if (!hasBackup) {
|
if (!hasBackup) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
|
|
@ -180,29 +173,11 @@ enum _BackupState { idle, signingIn, signingOut, backingUp, restoring }
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (confirmed != true || !mounted) return;
|
if (confirmed == true && mounted) {
|
||||||
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);
|
setState(() => _state = _BackupState.restoring);
|
||||||
|
final success = await _backupService.restoreBackup();
|
||||||
try {
|
|
||||||
final success = forceSkipPreBackup
|
|
||||||
? await _backupService.restoreBackupSkippingPreBackup()
|
|
||||||
: await _backupService.restoreBackup();
|
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() => _state = _BackupState.idle);
|
setState(() => _state = _BackupState.idle);
|
||||||
if (success) {
|
|
||||||
ref.invalidate(rawSakeListItemsProvider);
|
|
||||||
ref.invalidate(sakeSortOrderProvider);
|
|
||||||
ref.invalidate(userProfileProvider);
|
|
||||||
}
|
|
||||||
messenger.showSnackBar(
|
messenger.showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text(success ? '復元が完了しました' : '復元に失敗しました'),
|
content: Text(success ? '復元が完了しました' : '復元に失敗しました'),
|
||||||
|
|
@ -210,47 +185,6 @@ enum _BackupState { idle, signingIn, signingOut, backingUp, restoring }
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} 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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
||||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||||
# In Windows, build-name is used as the major, minor, and patch parts
|
# In Windows, build-name is used as the major, minor, and patch parts
|
||||||
# of the product and file versions while build-number is used as the build suffix.
|
# of the product and file versions while build-number is used as the build suffix.
|
||||||
version: 1.0.49+56
|
version: 1.0.47+54
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.10.1
|
sdk: ^3.10.1
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,19 @@
|
||||||
{
|
{
|
||||||
"version": "v1.0.49",
|
"version": "v1.0.46",
|
||||||
"name": "Ponshu Room 1.0.49 (2026-04-23)",
|
"name": "Ponshu Room 1.0.46 (2026-04-23)",
|
||||||
"date": "2026-04-23",
|
"date": "2026-04-23",
|
||||||
"apks": {
|
"apks": {
|
||||||
"maita": {
|
"maita": {
|
||||||
"lite": {
|
"lite": {
|
||||||
"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",
|
"url": "https://posimai-lab.tail72e846.ts.net/mai/ponshu-room-lite/releases/download/v1.0.46/ponshu_room_consumer_maita.apk",
|
||||||
"size_mb": 91
|
"size_mb": 91
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"eiji": {
|
"eiji": {
|
||||||
"lite": {
|
"lite": {
|
||||||
"filename": "ponshu_room_consumer_eiji.apk",
|
"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",
|
"url": "https://posimai-lab.tail72e846.ts.net/mai/ponshu-room-lite/releases/download/v1.0.46/ponshu_room_consumer_eiji.apk",
|
||||||
"size_mb": 91
|
"size_mb": 91
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue