feat(ai): Gemini 2段階解析実装(OCR→フル解析)でhallucination低減

Stage1でOCR専念(name/brand/prefecture確定)、Stage2で確定済み制約を
プロンプトに埋め込み残フィールドを推定する2段階フロー。
東魁→東魁盛のような銘柄補完hallucination緩和が目的。

- 直接APIモード(consumer APK)のみ2段階。プロキシ/キャッシュは従来通り。
- Stage1失敗時は1段階フォールバック(堅牢性維持)
- AnalyzingDialog: stageNotifier対応・ステップ1/2のメッセージ切り替え表示
- APIコール数は実質2倍(1日20回→実質10回相当)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Ponshu Developer 2026-04-23 16:37:57 +09:00
parent a5a5f729fe
commit bcba78a533
4 changed files with 369 additions and 180 deletions

View File

@ -124,18 +124,27 @@ 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) => const AnalyzingDialog(), builder: (context) => AnalyzingDialog(stageNotifier: stageNotifier),
); );
try { 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 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) // Create SakeItem (Schema v2.0)
final sakeItem = SakeItem( final sakeItem = SakeItem(
@ -380,6 +389,9 @@ mixin CameraAnalysisMixin<T extends ConsumerStatefulWidget> on ConsumerState<T>
), ),
); );
} }
} finally {
stageNotifierDisposed = true;
stageNotifier.dispose();
} }
} }

View File

@ -13,16 +13,274 @@ 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();
/// // ============================================================
Future<SakeAnalysisResult> analyzeSakeLabel(List<String> imagePaths, {bool forceRefresh = false}) async { // Public API
// // ============================================================
const prompt = '''
/// 2: OCR
///
/// [onStep1Complete]: Stage 1
/// UI 2使
/// APIモードconsumer APK1
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
## namebrandprefectureの読み取り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,
);
}
// ============================================================
// 2APIモード専用
// ============================================================
/// 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/2I/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専念301---
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);
}
// 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 = '''
3OCRしてください
-
- :
- 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 ? '"$name"' : 'null';
final brandJson = brand != null ? '"$brand"' : 'null';
final prefJson = prefecture != null ? '"$prefecture"' : 'null';
return '''
1OCR結果 3
OCRした確定結果です
- name: $nameConstraint
- brand: $brandConstraint
- prefecture: $prefConstraint
3JSONに含め
##
- type: null
- description: type 100
- catchCopy: 20
- flavorTags:
- tasteStats: 15 3
- alcoholContent: type
- polishingRatio: type
- sakeMeterValue:
- riceVariety: null
- yeast: null
- manufacturingYearMonth: null
- confidenceScore: 0100
##
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を返してください
@ -86,93 +344,15 @@ name・brand を出力する直前に以下を確認してください:
} }
'''; ''';
return _callProxyApi( // ============================================================
imagePaths: imagePaths, // APIコールiOSビルド用 / USE_PROXY=true
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
## namebrandprefectureの読み取り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);
@ -193,21 +373,20 @@ name・brand を出力する直前に以下を確認してください:
} }
_lastApiCallTime = DateTime.now(); _lastApiCallTime = DateTime.now();
// 2. Base64変換 // 3. 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');
} }
// 3. ID取得 // 4. ID取得
final deviceId = await DeviceService.getDeviceId(); final deviceId = await DeviceService.getDeviceId();
if (kDebugMode) debugPrint('Device ID: $deviceId'); if (kDebugMode) debugPrint('Device ID: $deviceId');
// 4. // 5.
final requestBody = jsonEncode({ final requestBody = jsonEncode({
"device_id": deviceId, "device_id": deviceId,
"images": base64Images, "images": base64Images,
@ -216,7 +395,7 @@ name・brand を出力する直前に以下を確認してください:
debugPrint('Calling Proxy: $_proxyUrl'); debugPrint('Calling Proxy: $_proxyUrl');
// 5. Bearer Token認証付き // 6. Bearer Token認証付き
final headers = { final headers = {
"Content-Type": "application/json", "Content-Type": "application/json",
if (Secrets.proxyAuthToken.isNotEmpty) if (Secrets.proxyAuthToken.isNotEmpty)
@ -226,18 +405,16 @@ name・brand を出力する直前に以下を確認してください:
Uri.parse(_proxyUrl), Uri.parse(_proxyUrl),
headers: headers, headers: headers,
body: requestBody, body: requestBody,
).timeout(const Duration(seconds: 60)); // : 60 () ).timeout(const Duration(seconds: 60));
// 6. // 7.
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']}');
@ -245,9 +422,6 @@ 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);
@ -260,11 +434,9 @@ 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}');
} }
@ -273,7 +445,6 @@ 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明日またお試しください。');
@ -282,10 +453,16 @@ name・brand を出力する直前に以下を確認してください:
} }
} }
/// Direct Cloud API Implementation (No Proxy) // ============================================================
Future<SakeAnalysisResult> _callDirectApi(List<String> imagePaths, String? customPrompt, {bool forceRefresh = false, double temperature = 0}) async { // APIコールconsumer APK / USE_PROXY=false
// 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);
@ -295,65 +472,22 @@ 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.0-flash'; const fallbackModel = 'gemini-2.0-flash';
// customPrompt analyzeSakeLabel null final promptText = customPrompt ?? _mainAnalysisPrompt;
final promptText = customPrompt ?? '''
JSONを返してください
## namebrandprefectureの読み取りOCR厳守
3
/ / 鹿鹿
N N
- prefecture: null
##
使
- tasteStats: 15 3
- alcoholContentpolishingRatio: 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];
@ -398,11 +532,9 @@ 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,
@ -419,22 +551,20 @@ 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) {
// or 503 if (is503) throw const GeminiCongestionException();
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;
@ -446,7 +576,6 @@ 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;
@ -455,7 +584,6 @@ class SakeAnalysisResult {
final String? manufacturingYearMonth; final String? manufacturingYearMonth;
/// EXP付与使 /// EXP付与使
/// JSON false
final bool isFromCache; final bool isFromCache;
SakeAnalysisResult({ SakeAnalysisResult({
@ -477,7 +605,6 @@ 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,
@ -489,7 +616,6 @@ class SakeAnalysisResult {
); );
factory SakeAnalysisResult.fromJson(Map<String, dynamic> json) { factory SakeAnalysisResult.fromJson(Map<String, dynamic> json) {
// tasteStats: 3 (15)
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) {
@ -522,7 +648,6 @@ class SakeAnalysisResult {
); );
} }
/// JSON形式に変換
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
return { return {
'name': name, 'name': name,

View File

@ -2,7 +2,12 @@ import 'package:flutter/material.dart';
import 'dart:async'; import 'dart:async';
class AnalyzingDialog extends StatefulWidget { 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 @override
State<AnalyzingDialog> createState() => _AnalyzingDialogState(); State<AnalyzingDialog> createState() => _AnalyzingDialogState();
@ -10,32 +15,70 @@ class AnalyzingDialog extends StatefulWidget {
class _AnalyzingDialogState extends State<AnalyzingDialog> { class _AnalyzingDialogState extends State<AnalyzingDialog> {
int _messageIndex = 0; 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 @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() {
Future.delayed(const Duration(milliseconds: 1500), () { _timer = Timer.periodic(const Duration(milliseconds: 1800), (timer) {
if (mounted && _messageIndex < _messages.length - 1) { if (!mounted) {
timer.cancel();
return;
}
final messages = _currentMessages;
if (_messageIndex < messages.length - 1) {
setState(() => _messageIndex++); setState(() => _messageIndex++);
_startMessageRotation(); } else {
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),
@ -45,10 +88,19 @@ class _AnalyzingDialogState extends State<AnalyzingDialog> {
const CircularProgressIndicator(), const CircularProgressIndicator(),
const SizedBox(height: 24), const SizedBox(height: 24),
Text( Text(
_messages[_messageIndex], messages[safeIndex],
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),
),
),
],
], ],
), ),
), ),

View File

@ -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.47+54 version: 1.0.48+55
environment: environment:
sdk: ^3.10.1 sdk: ^3.10.1