Compare commits

..

10 Commits

Author SHA1 Message Date
Ponshu Developer 073e55cc51 chore: update download page to v1.0.49 2026-04-23 22:32:46 +09:00
Ponshu Developer 856e349848 fix(ai): フォールバックモデルをgemini-2.0-flash(廃止)→gemini-2.5-flash-liteに変更
gemini-2.0-flashはdeprecated済みで、primary(gemini-2.5-flash)が3回失敗した際に
廃止済みモデルへ落ちて確実にエラーになっていた。フォールバックを現役の
gemini-2.5-flash-liteに変更することで「解析に失敗しました」を解消する。

また、エラーメッセージにHTTPステータスコード等の短い補足を追加し、
次回の障害診断を容易にする(例: [404] [key?] [timeout])。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 22:25:12 +09:00
Ponshu Developer 778d2a725a fix(ai): 2段階解析のバグ2件修正
- Stage1でname/brandが両方nullの場合は無意味なStage2をスキップして1段階フォールバック
- nameJson/brandJsonをjsonEncode()でエスケープ(特殊文字含む銘柄名でのプロンプト破壊を防止)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 17:05:55 +09:00
Ponshu Developer bcba78a533 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>
2026-04-23 16:37:57 +09:00
Ponshu Developer a5a5f729fe chore: bump to v1.0.47, update download page 2026-04-23 13:11:40 +09:00
Ponshu Developer 582553ccfa fix(ai): 再解析を専用プロンプト+temperature=0.3に変更(東魁hallucination対策)
- reanalyzeSakeLabel() を新設: 前回のname/brandを渡し「本当に正しいか?」と問い直す
- 通常解析(temperature=0)と再解析(temperature=0.3)を分離
  → 同じ画像で毎回同じ誤答を返す問題を解消
- _callDirectApi に temperature パラメータを追加
2026-04-23 13:07:55 +09:00
Ponshu Developer 902128a3ff fix(ci): Flutter 3.38.x に更新(Dart ^3.10.1 対応)、CLAUDE.md 追加(デプロイルール明文化) 2026-04-23 12:58:42 +09:00
Ponshu Developer 797dd67000 chore: update download page to v1.0.46 2026-04-23 12:15:39 +09:00
Ponshu Developer 191274c65a chore: bump version to 1.0.46+53 (secure_storage + flicker fix) 2026-04-23 12:07:14 +09:00
Ponshu Developer ab18b544c2 security: ライセンスキーを flutter_secure_storage へ移行、Pro UI ちらつき修正
- SharedPreferences のライセンスキーを FlutterSecureStorage(Android 暗号化)に移行
- 既存ユーザー向け一回限りのマイグレーション処理を追加(ponshu_license_migrated_v1 フラグ)
- LicenseService.getCachedStatusOnly() を追加(ネットワーク不要の即時キャッシュ返却)
- licenseStatusProvider を FutureProvider から AsyncNotifier に変換
  - main() でキャッシュを事前ロードし licenseInitialStatusProvider に渡すことで
    起動時の loading → false → pro のちらつきを根本解消
  - バックグラウンドでサーバー検証を実行し、差異があれば状態を更新

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 10:23:18 +09:00
19 changed files with 798 additions and 188 deletions

View File

@ -17,7 +17,7 @@ jobs:
- name: Set up Flutter - name: Set up Flutter
uses: subosito/flutter-action@v2 uses: subosito/flutter-action@v2
with: with:
flutter-version: '3.29.x' flutter-version: '3.38.x'
channel: 'stable' channel: 'stable'
- name: Install dependencies - name: Install dependencies

56
CLAUDE.md Normal file
View File

@ -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 storeprovider=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 がバイナリに入る

View File

@ -9,6 +9,8 @@ import 'providers/theme_provider.dart';
import 'screens/main_screen.dart'; import 'screens/main_screen.dart';
import 'screens/license_screen.dart'; import 'screens/license_screen.dart';
import 'services/migration_service.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( runApp(
const ProviderScope( ProviderScope(
child: MyApp(), overrides: [
licenseInitialStatusProvider.overrideWithValue(cachedLicenseStatus),
],
child: const MyApp(),
), ),
); );
} }

View File

@ -1,15 +1,52 @@
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../services/license_service.dart'; import '../services/license_service.dart';
/// ///
/// ///
/// VPSに問い合わせ /// main() override
/// [licenseStatusProvider].invalidate() /// licenseStatusProvider loading AsyncData
final licenseStatusProvider = FutureProvider<LicenseStatus>((ref) async { final licenseInitialStatusProvider = Provider<LicenseStatus?>((ref) => null);
return LicenseService.checkStatus();
}); ///
///
/// - 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版かどうか使 /// Pro版かどうか使
///
/// licenseStatusProvider AsyncData
/// false
final isProProvider = Provider<bool>((ref) { final isProProvider = Provider<bool>((ref) {
final statusAsync = ref.watch(licenseStatusProvider); final statusAsync = ref.watch(licenseStatusProvider);
return statusAsync.maybeWhen( return statusAsync.maybeWhen(

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(
@ -372,17 +381,38 @@ 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: const Text('解析に失敗しました。時間をおいて再試行してください。'), content: Text('解析に失敗しました。時間をおいて再試行してください。$errDetail'),
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 '';
}
/// ///
/// ///
/// ///

View File

@ -358,6 +358,15 @@ 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(

View File

@ -345,7 +345,13 @@ class _SakeDetailScreenState extends ConsumerState<SakeDetailScreen> {
showDialog(context: context, barrierDismissible: false, builder: (context) => const AnalyzingDialog()); showDialog(context: context, barrierDismissible: false, builder: (context) => const AnalyzingDialog());
final geminiService = ref.read(geminiServiceProvider); final geminiService = ref.read(geminiServiceProvider);
final result = await geminiService.analyzeSakeLabel(existingPaths, forceRefresh: true); // : name/brand
// temperature=0.3 hallucination
final result = await geminiService.reanalyzeSakeLabel(
existingPaths,
previousName: _sake.displayData.displayName,
previousBrand: _sake.displayData.displayBrewery,
);
final newItem = _sake.copyWith( final newItem = _sake.copyWith(
name: result.name ?? _sake.displayData.displayName, name: result.name ?? _sake.displayData.displayName,

View File

@ -2,6 +2,7 @@ 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';
@ -22,7 +23,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 isDark = Theme.of(context).brightness == Brightness.dark; final appColors = Theme.of(context).extension<AppColors>()!;
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
@ -35,25 +36,25 @@ class _ShopSettingsScreenState extends ConsumerState<ShopSettingsScreen> {
// Business Config Section // Business Config Section
_buildSectionHeader(context, '価格設定', LucideIcons.briefcase), _buildSectionHeader(context, '価格設定', LucideIcons.briefcase),
Card( Card(
color: isDark ? const Color(0xFF1E1E1E) : null, color: appColors.surfaceElevated,
child: ListTile( 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('基本掛率'), title: const Text('基本掛率'),
trailing: Row( trailing: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Text('×', style: TextStyle( Text('×', style: TextStyle(
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
fontSize: 16, fontSize: 16,
color: isDark ? Colors.grey[400] : Colors.grey[600], color: appColors.textSecondary,
)), )),
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: isDark ? Colors.grey[800] : Colors.grey[100], color: appColors.surfaceSubtle,
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
border: Border.all(color: isDark ? Colors.grey[700]! : Colors.grey[300]!), border: Border.all(color: appColors.divider),
), ),
child: DropdownButton<double>( child: DropdownButton<double>(
value: userProfile.defaultMarkup, value: userProfile.defaultMarkup,
@ -96,18 +97,18 @@ class _ShopSettingsScreenState extends ConsumerState<ShopSettingsScreen> {
} }
Widget _buildSectionHeader(BuildContext context, String title, IconData icon) { 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( 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: isDark ? Colors.orange[300] : Theme.of(context).primaryColor), Icon(icon, size: 20, color: appColors.iconAccent),
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: isDark ? Colors.grey[300] : Theme.of(context).primaryColor, color: appColors.textPrimary,
), ),
), ),
], ],

View File

@ -11,6 +11,15 @@ 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へのバックアップ
/// ///
/// ///
@ -325,8 +334,11 @@ class BackupService {
final driveApi = drive.DriveApi(authClient); final driveApi = drive.DriveApi(authClient);
// 3. 退 // 3. 退
await _createPreRestoreBackup(); final preBackupOk = await _createPreRestoreBackup();
if (!preBackupOk) {
throw const PreRestoreBackupException();
}
// 4. Google Driveからダウンロード // 4. Google Driveからダウンロード
final zipFile = await _downloadFromDrive(driveApi); final zipFile = await _downloadFromDrive(driveApi);
@ -342,26 +354,55 @@ 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<void> _createPreRestoreBackup() async { Future<bool> _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) {
await zipFile.copy(backupPath); debugPrint('[RESTORE] Pre-restore backup: ZIP creation failed');
await zipFile.delete(); return false;
debugPrint('[RESTORE] Pre-restore backup saved: $backupPath');
} }
await zipFile.copy(backupPath);
await zipFile.delete();
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;
} }
} }
@ -393,7 +434,14 @@ class BackupService {
// 3. // 3.
final sink = downloadFile.openWrite(); 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'); debugPrint('[RESTORE] Download complete: $downloadPath');
return downloadFile; return downloadFile;
@ -484,7 +532,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(), markup: (data['userData']['markup'] as num?)?.toDouble() ?? 3.0,
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,

View File

@ -12,17 +12,281 @@ import 'gemini_exceptions.dart';
class GeminiService { 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);
}
// 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 = '''
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 ? jsonEncode(name) : 'null';
final brandJson = brand != null ? jsonEncode(brand) : 'null';
final prefJson = prefecture != null ? jsonEncode(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,25 +350,15 @@ name・brand を出力する直前に以下を確認してください:
} }
'''; ''';
return _callProxyApi( // ============================================================
imagePaths: imagePaths, // APIコールiOSビルド用 / USE_PROXY=true
customPrompt: prompt, // Override server default // ============================================================
forceRefresh: forceRefresh,
);
}
/// : 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);
@ -125,21 +379,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,
@ -148,7 +401,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)
@ -158,18 +411,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']}');
@ -177,9 +428,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);
@ -192,11 +440,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}');
} }
@ -205,7 +451,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明日またお試しください。');
@ -214,10 +459,16 @@ name・brand を出力する直前に以下を確認してください:
} }
} }
/// Direct Cloud API Implementation (No Proxy) // ============================================================
Future<SakeAnalysisResult> _callDirectApi(List<String> imagePaths, String? customPrompt, {bool forceRefresh = false}) 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);
@ -227,65 +478,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.5-flash-lite';
// 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];
@ -316,7 +524,7 @@ name・brand を出力する直前に以下を確認してください:
), ),
generationConfig: GenerationConfig( generationConfig: GenerationConfig(
responseMimeType: 'application/json', responseMimeType: 'application/json',
temperature: 0, temperature: temperature,
), ),
); );
@ -330,11 +538,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,
@ -351,22 +557,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;
@ -378,7 +582,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;
@ -387,7 +590,6 @@ class SakeAnalysisResult {
final String? manufacturingYearMonth; final String? manufacturingYearMonth;
/// EXP付与使 /// EXP付与使
/// JSON false
final bool isFromCache; final bool isFromCache;
SakeAnalysisResult({ SakeAnalysisResult({
@ -409,7 +611,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,
@ -421,7 +622,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) {
@ -454,7 +654,6 @@ class SakeAnalysisResult {
); );
} }
/// JSON形式に変換
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
return { return {
'name': name, 'name': name,

View File

@ -1,5 +1,6 @@
import 'dart:convert'; import 'dart:convert';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'device_service.dart'; import 'device_service.dart';
@ -22,43 +23,81 @@ enum LicenseStatus {
/// ///
/// ///
/// ##
/// - : flutter_secure_storage
/// - : SharedPreferences
///
/// ## /// ##
/// 1. : VPSに問い合わせた結果を使用 + SharedPreferencesにキャッシュ /// 1. : SecureStorage
/// 2. : SharedPreferencesのキャッシュを使用 (Pro状態を維持) /// 2. : VPS
/// 3. : SharedPreferences
/// ///
/// ## /// ##
/// PONSHU-XXXX-XXXX-XXXX (6 = 12hex文字, ) /// PONSHU-XXXX-XXXX-XXXX (6 = 12hex文字, )
class LicenseService { class LicenseService {
static const _prefLicenseKey = 'ponshu_license_key'; static const _secureKeyName = 'ponshu_license_key';
static const _prefCachedStatus = 'ponshu_license_status_cache'; static const _prefCachedStatus = 'ponshu_license_status_cache';
static const _prefCachedAt = 'ponshu_license_cached_at'; 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 ========== // ========== Public API ==========
/// : ///
static Future<LicenseStatus> checkStatus() async { ///
final prefs = await SharedPreferences.getInstance(); /// main() await runApp()
final savedKey = prefs.getString(_prefLicenseKey) ?? ''; 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();
return _getCachedStatus(prefs);
}
/// : VPS
static Future<LicenseStatus> checkStatus() async {
await _migrateIfNeeded();
final savedKey = await _storage.read(key: _secureKeyName) ?? '';
//
if (savedKey.isNotEmpty) { if (savedKey.isNotEmpty) {
try { try {
final status = await _validateKeyWithServer(savedKey); final status = await _validateKeyWithServer(savedKey);
if (status == LicenseStatus.offline) { if (status == LicenseStatus.offline) {
// :
debugPrint('[License] Server unreachable, using cache'); debugPrint('[License] Server unreachable, using cache');
final prefs = await SharedPreferences.getInstance();
return _getCachedStatus(prefs); return _getCachedStatus(prefs);
} }
final prefs = await SharedPreferences.getInstance();
await _cacheStatus(prefs, status); await _cacheStatus(prefs, status);
return status; return status;
} catch (e) { } catch (e) {
debugPrint('[License] Server unreachable, using cache: $e'); debugPrint('[License] Server unreachable, using cache: $e');
final prefs = await SharedPreferences.getInstance();
return _getCachedStatus(prefs); return _getCachedStatus(prefs);
} }
} }
//
return LicenseStatus.free; return LicenseStatus.free;
} }
@ -76,8 +115,8 @@ class LicenseService {
final status = await _validateKeyWithServer(key); final status = await _validateKeyWithServer(key);
if (status == LicenseStatus.pro) { if (status == LicenseStatus.pro) {
await _storage.write(key: _secureKeyName, value: key);
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
await prefs.setString(_prefLicenseKey, key);
await _cacheStatus(prefs, LicenseStatus.pro); await _cacheStatus(prefs, LicenseStatus.pro);
debugPrint('[License] Activated successfully.'); debugPrint('[License] Activated successfully.');
return (success: true, message: ''); return (success: true, message: '');
@ -96,14 +135,14 @@ class LicenseService {
/// ///
static Future<bool> hasLicenseKey() async { static Future<bool> hasLicenseKey() async {
final prefs = await SharedPreferences.getInstance(); await _migrateIfNeeded();
return (prefs.getString(_prefLicenseKey) ?? '').isNotEmpty; return ((await _storage.read(key: _secureKeyName)) ?? '').isNotEmpty;
} }
/// ///
static Future<void> reset() async { static Future<void> reset() async {
await _storage.delete(key: _secureKeyName);
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
await prefs.remove(_prefLicenseKey);
await prefs.remove(_prefCachedStatus); await prefs.remove(_prefCachedStatus);
await prefs.remove(_prefCachedAt); await prefs.remove(_prefCachedAt);
debugPrint('[License] Reset complete.'); debugPrint('[License] Reset complete.');
@ -128,7 +167,11 @@ class LicenseService {
final data = jsonDecode(utf8.decode(response.bodyBytes)) as Map<String, dynamic>; final data = jsonDecode(utf8.decode(response.bodyBytes)) as Map<String, dynamic>;
if (data['valid'] == true) return LicenseStatus.pro; if (data['valid'] == true) return LicenseStatus.pro;
if ((data['error'] as String? ?? '').contains('無効化')) return LicenseStatus.revoked; // revoked booleanerror
if (data['revoked'] == true ||
(data['error'] as String? ?? '').contains('無効化')) {
return LicenseStatus.revoked;
}
return LicenseStatus.free; return LicenseStatus.free;
} catch (e) { } catch (e) {
@ -143,19 +186,18 @@ class LicenseService {
} }
static LicenseStatus _getCachedStatus(SharedPreferences prefs) { static LicenseStatus _getCachedStatus(SharedPreferences prefs) {
final cached = prefs.getString(_prefCachedStatus); final cached = prefs.getString(_prefCachedStatus);
final cachedAt = prefs.getString(_prefCachedAt); final cachedAt = prefs.getString(_prefCachedAt);
if (cached == null) return LicenseStatus.free; if (cached == null) return LicenseStatus.free;
// _validateKeyWithServer // checkStatus
// _getCachedStatus // _getCachedStatus
// //
// TTL _cacheValidSeconds = 24h: // TTL _cacheValidSeconds = 24h:
// - free / offline free // - free / offline: free
// - pro : // - pro :
// - revoked: // - revoked:
// TTL isNoExpiryStatus
if (cachedAt != null) { if (cachedAt != null) {
final age = DateTime.now().difference(DateTime.parse(cachedAt)); final age = DateTime.now().difference(DateTime.parse(cachedAt));
final isNoExpiryStatus = cached == LicenseStatus.pro.name || cached == LicenseStatus.revoked.name; final isNoExpiryStatus = cached == LicenseStatus.pro.name || cached == LicenseStatus.revoked.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

@ -272,6 +272,7 @@ class _SakeDetailSpecsState extends State<SakeDetailSpecs> {
_isEditing, _isEditing,
suffixIcon: LucideIcons.calendar, suffixIcon: LucideIcons.calendar,
onSuffixTap: () => _showDatePicker(context), onSuffixTap: () => _showDatePicker(context),
helperText: '例: 2023-10',
), ),
], ],
), ),
@ -303,10 +304,11 @@ 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 // Parse current value or use now (AI出力 "2023.10" "2023-10" )
DateTime initialDate = DateTime.now(); DateTime initialDate = DateTime.now();
try { try {
final parts = _manufacturingController.text.split('-'); final normalized = _manufacturingController.text.replaceAll('.', '-');
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]);

View File

@ -1,9 +1,12 @@
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 StatefulWidget { class BackupSettingsSection extends ConsumerStatefulWidget {
final String title; final String title;
const BackupSettingsSection({ const BackupSettingsSection({
@ -12,12 +15,12 @@ class BackupSettingsSection extends StatefulWidget {
}); });
@override @override
State<BackupSettingsSection> createState() => _BackupSettingsSectionState(); ConsumerState<BackupSettingsSection> createState() => _BackupSettingsSectionState();
} }
enum _BackupState { idle, signingIn, signingOut, backingUp, restoring } enum _BackupState { idle, signingIn, signingOut, backingUp, restoring }
class _BackupSettingsSectionState extends State<BackupSettingsSection> { class _BackupSettingsSectionState extends ConsumerState<BackupSettingsSection> {
final BackupService _backupService = BackupService(); final BackupService _backupService = BackupService();
_BackupState _state = _BackupState.idle; _BackupState _state = _BackupState.idle;
@ -28,7 +31,11 @@ enum _BackupState { idle, signingIn, signingOut, backingUp, restoring }
} }
Future<void> _initBackupService() async { Future<void> _initBackupService() async {
await _backupService.init(); try {
await _backupService.init();
} catch (e) {
debugPrint('[Backup] Init error (silent sign-in failed): $e');
}
if (mounted) { if (mounted) {
setState(() {}); setState(() {});
} }
@ -132,7 +139,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) {
@ -173,11 +180,29 @@ enum _BackupState { idle, signingIn, signingOut, backingUp, restoring }
), ),
); );
if (confirmed == true && mounted) { if (confirmed != true || !mounted) return;
setState(() => _state = _BackupState.restoring); await _executeRestore(forceSkipPreBackup: false);
final success = await _backupService.restoreBackup(); }
/// PreRestoreBackupException
Future<void> _executeRestore({required bool forceSkipPreBackup}) async {
final messenger = ScaffoldMessenger.of(context);
final appColors = Theme.of(context).extension<AppColors>()!;
setState(() => _state = _BackupState.restoring);
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 ? '復元が完了しました' : '復元に失敗しました'),
@ -185,6 +210,47 @@ 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);
}
} }
} }

View File

@ -515,6 +515,54 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.1.0" 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: flutter_speed_dial:
dependency: "direct main" dependency: "direct main"
description: description:
@ -801,10 +849,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: js name: js
sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc" sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.2" version: "0.6.7"
json_annotation: json_annotation:
dependency: transitive dependency: transitive
description: description:

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.45+52 version: 1.0.49+56
environment: environment:
sdk: ^3.10.1 sdk: ^3.10.1
@ -46,7 +46,7 @@ dependencies:
device_info_plus: ^10.1.0 device_info_plus: ^10.1.0
http: ^1.2.0 http: ^1.2.0
crypto: ^3.0.3 # SHA-256 ハッシュ計算用(キャッシュ) crypto: ^3.0.3 # SHA-256 ハッシュ計算用(キャッシュ)
lucide_icons: ^0.257.0 lucide_icons: 0.257.0
reorderable_grid_view: ^2.2.5 reorderable_grid_view: ^2.2.5
camera: ^0.11.3 camera: ^0.11.3
path_provider: ^2.1.5 path_provider: ^2.1.5
@ -61,6 +61,7 @@ dependencies:
package_info_plus: ^8.1.2 package_info_plus: ^8.1.2
gal: ^2.3.0 gal: ^2.3.0
shared_preferences: ^2.5.4 shared_preferences: ^2.5.4
flutter_secure_storage: ^9.2.2
# Phase 9: Google Drive Backup # Phase 9: Google Drive Backup
googleapis: ^13.2.0 googleapis: ^13.2.0

View File

@ -1,20 +1,20 @@
{ {
"version": "v1.0.43", "version": "v1.0.49",
"name": "Ponshu Room 1.0.43 (2026-04-18)", "name": "Ponshu Room 1.0.49 (2026-04-23)",
"date": "2026-04-18", "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.43/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": 89 "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.43/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": 89 "size_mb": 91
} }
} }
} }

View File

@ -8,6 +8,7 @@
#include <connectivity_plus/connectivity_plus_windows_plugin.h> #include <connectivity_plus/connectivity_plus_windows_plugin.h>
#include <file_selector_windows/file_selector_windows.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 <gal/gal_plugin_c_api.h>
#include <printing/printing_plugin.h> #include <printing/printing_plugin.h>
#include <share_plus/share_plus_windows_plugin_c_api.h> #include <share_plus/share_plus_windows_plugin_c_api.h>
@ -18,6 +19,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin")); registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin"));
FileSelectorWindowsRegisterWithRegistrar( FileSelectorWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FileSelectorWindows")); registry->GetRegistrarForPlugin("FileSelectorWindows"));
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
GalPluginCApiRegisterWithRegistrar( GalPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("GalPluginCApi")); registry->GetRegistrarForPlugin("GalPluginCApi"));
PrintingPluginRegisterWithRegistrar( PrintingPluginRegisterWithRegistrar(

View File

@ -5,6 +5,7 @@
list(APPEND FLUTTER_PLUGIN_LIST list(APPEND FLUTTER_PLUGIN_LIST
connectivity_plus connectivity_plus
file_selector_windows file_selector_windows
flutter_secure_storage_windows
gal gal
printing printing
share_plus share_plus