Compare commits
10 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
073e55cc51 | |
|
|
856e349848 | |
|
|
778d2a725a | |
|
|
bcba78a533 | |
|
|
a5a5f729fe | |
|
|
582553ccfa | |
|
|
902128a3ff | |
|
|
797dd67000 | |
|
|
191274c65a | |
|
|
ab18b544c2 |
|
|
@ -17,7 +17,7 @@ jobs:
|
|||
- name: Set up Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
flutter-version: '3.29.x'
|
||||
flutter-version: '3.38.x'
|
||||
channel: 'stable'
|
||||
|
||||
- name: Install dependencies
|
||||
|
|
|
|||
|
|
@ -0,0 +1,56 @@
|
|||
# Ponshu Room Lite — AI 規約
|
||||
|
||||
## デプロイ手順(必ず守ること)
|
||||
|
||||
### Android APK ビルド
|
||||
```bash
|
||||
bash build_consumer.sh # maita + eiji の consumer APK を生成
|
||||
bash build_4_apks.sh # 全4バリアント(consumer + business × maita/eiji)
|
||||
```
|
||||
- `.env` から `MAITA_API_KEY` / `EIJI_API_KEY` を読んで `--dart-define` に渡す
|
||||
- 直接 `flutter build apk` を叩かないこと(キーが secrets.local.dart にフォールバックする)
|
||||
|
||||
### ダウンロードページ(Vercel)
|
||||
```bash
|
||||
# 必ず web/download/ ディレクトリから実行すること(web/ からではない)
|
||||
cd web/download && vercel --prod
|
||||
vercel alias set <deployment-url> ponshu-room-download.vercel.app
|
||||
```
|
||||
- **URL**: https://ponshu-room-download.vercel.app
|
||||
- `releases.json` を更新してからデプロイする
|
||||
- `web/` ルートは Flutter web アプリなので絶対にデプロイしない
|
||||
|
||||
### Gitea リリース(APK アップロード)
|
||||
```bash
|
||||
# APK ビルド後に Gitea の API でリリース作成 → アセットアップロード
|
||||
# 認証: git credential store(provider=generic)から自動取得
|
||||
GITEA_TOKEN=$(echo "protocol=http\nhost=100.76.7.3:3000" | git credential fill | grep password | cut -d= -f2)
|
||||
```
|
||||
|
||||
### iOS / TestFlight
|
||||
- GitHub tag push(`v*`)で自動トリガー(.github/workflows/ios_build.yml)
|
||||
- Flutter バージョンは **3.38.x** を使用(pubspec の sdk: ^3.10.1 に対応)
|
||||
|
||||
## リリース手順チェックリスト
|
||||
|
||||
1. `pubspec.yaml` のバージョン番号を上げる
|
||||
2. `git tag vX.Y.Z && git push gitea main`(タグも push)
|
||||
3. `bash build_consumer.sh` で APK ビルド
|
||||
4. Gitea API でリリース作成 + APK アップロード
|
||||
5. `web/download/releases.json` を新バージョンに更新
|
||||
6. `cd web/download && vercel --prod` → alias set
|
||||
7. iOS CI は GitHub tag push で自動実行
|
||||
|
||||
## ディレクトリ構成の注意点
|
||||
|
||||
| ディレクトリ | 内容 | デプロイ先 |
|
||||
|-------------|------|-----------|
|
||||
| `lib/` | Flutter アプリ本体 | APK / TestFlight |
|
||||
| `web/download/` | ダウンロードページ | ponshu-room-download.vercel.app |
|
||||
| `web/` ルート | Flutter web ビルド出力 | **デプロイ対象外** |
|
||||
|
||||
## secrets の扱い
|
||||
|
||||
- `lib/secrets.local.dart` — gitignore 済み。ローカル開発専用
|
||||
- リリースビルドは必ず `build_consumer.sh` 経由(`--dart-define` でキーを注入)
|
||||
- 直接 `flutter build apk --release` すると secrets.local.dart がバイナリに入る
|
||||
|
|
@ -9,6 +9,8 @@ import 'providers/theme_provider.dart';
|
|||
import 'screens/main_screen.dart';
|
||||
import 'screens/license_screen.dart';
|
||||
import 'services/migration_service.dart';
|
||||
import 'services/license_service.dart';
|
||||
import 'providers/license_provider.dart';
|
||||
|
||||
/// 店舗向けビルドかどうかを判定するビルド時フラグ
|
||||
///
|
||||
|
|
@ -74,9 +76,16 @@ void main() async {
|
|||
}
|
||||
}
|
||||
|
||||
// ちらつき防止: runApp の前にキャッシュ済みライセンス状態を取得し、
|
||||
// licenseStatusProvider が loading を経由せず即 AsyncData になるよう override する
|
||||
final cachedLicenseStatus = await LicenseService.getCachedStatusOnly();
|
||||
|
||||
runApp(
|
||||
const ProviderScope(
|
||||
child: MyApp(),
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
licenseInitialStatusProvider.overrideWithValue(cachedLicenseStatus),
|
||||
],
|
||||
child: const MyApp(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,52 @@
|
|||
import 'dart:async';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../services/license_service.dart';
|
||||
|
||||
/// ライセンス状態の非同期プロバイダー
|
||||
/// 起動時にキャッシュから事前ロードした値を保持するプロバイダー
|
||||
///
|
||||
/// アプリ起動時に一度だけVPSに問い合わせ、結果をキャッシュする。
|
||||
/// 手動更新は [licenseStatusProvider].invalidate() を呼ぶ。
|
||||
final licenseStatusProvider = FutureProvider<LicenseStatus>((ref) async {
|
||||
/// main() で override して初期値を渡すことで、
|
||||
/// licenseStatusProvider が loading 状態を経由せず即座に AsyncData になる。
|
||||
final licenseInitialStatusProvider = Provider<LicenseStatus?>((ref) => null);
|
||||
|
||||
/// ライセンス状態プロバイダー
|
||||
///
|
||||
/// - build() は licenseInitialStatusProvider に値がある場合は同期で返す(ちらつきなし)
|
||||
/// - 同時にバックグラウンドでサーバー検証を実行し、差異があれば状態を更新する
|
||||
final licenseStatusProvider =
|
||||
AsyncNotifierProvider<LicenseStatusNotifier, LicenseStatus>(
|
||||
LicenseStatusNotifier.new,
|
||||
);
|
||||
|
||||
class LicenseStatusNotifier extends AsyncNotifier<LicenseStatus> {
|
||||
@override
|
||||
FutureOr<LicenseStatus> build() {
|
||||
final initial = ref.read(licenseInitialStatusProvider);
|
||||
if (initial != null) {
|
||||
// キャッシュ値を同期で返すことで loading 状態をスキップ
|
||||
_refreshFromServer();
|
||||
return initial;
|
||||
}
|
||||
// override なし(テスト等): 通常フロー
|
||||
return LicenseService.checkStatus();
|
||||
});
|
||||
}
|
||||
|
||||
/// バックグラウンドでサーバー検証を実行し、変化があれば状態を更新する
|
||||
Future<void> _refreshFromServer() async {
|
||||
try {
|
||||
final fresh = await LicenseService.checkStatus();
|
||||
if (state.hasValue && state.value != fresh) {
|
||||
state = AsyncData(fresh);
|
||||
}
|
||||
} catch (_) {
|
||||
// ネットワークエラーはキャッシュ値を維持
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Pro版かどうか(ナビゲーション・機能解放の分岐に使う)
|
||||
///
|
||||
/// licenseStatusProvider が同期で AsyncData を返すため、
|
||||
/// アプリ起動時に false をちらつかせることなく正しい値を返す。
|
||||
final isProProvider = Provider<bool>((ref) {
|
||||
final statusAsync = ref.watch(licenseStatusProvider);
|
||||
return statusAsync.maybeWhen(
|
||||
|
|
|
|||
|
|
@ -124,18 +124,27 @@ mixin CameraAnalysisMixin<T extends ConsumerStatefulWidget> on ConsumerState<T>
|
|||
|
||||
// オンライン時: 通常の解析フロー
|
||||
if (!mounted) return;
|
||||
|
||||
final stageNotifier = ValueNotifier<int>(1);
|
||||
var stageNotifierDisposed = false;
|
||||
|
||||
// ignore: use_build_context_synchronously
|
||||
// 直前の mounted チェックにより BuildContext の有効性は保証されている
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) => const AnalyzingDialog(),
|
||||
builder: (context) => AnalyzingDialog(stageNotifier: stageNotifier),
|
||||
);
|
||||
|
||||
try {
|
||||
debugPrint('Starting Gemini Vision Direct Analysis for ${capturedImages.length} images');
|
||||
debugPrint('Starting Gemini 2-stage analysis for ${capturedImages.length} images');
|
||||
final geminiService = ref.read(geminiServiceProvider);
|
||||
final result = await geminiService.analyzeSakeLabel(capturedImages);
|
||||
final result = await geminiService.analyzeSakeLabel(
|
||||
capturedImages,
|
||||
onStep1Complete: () {
|
||||
if (!stageNotifierDisposed) stageNotifier.value = 2;
|
||||
},
|
||||
);
|
||||
|
||||
// Create SakeItem (Schema v2.0)
|
||||
final sakeItem = SakeItem(
|
||||
|
|
@ -372,17 +381,38 @@ mixin CameraAnalysisMixin<T extends ConsumerStatefulWidget> on ConsumerState<T>
|
|||
}
|
||||
|
||||
debugPrint('Analysis error: $e');
|
||||
final errDetail = _extractErrorCode(e.toString());
|
||||
messenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: const Text('解析に失敗しました。時間をおいて再試行してください。'),
|
||||
content: Text('解析に失敗しました。時間をおいて再試行してください。$errDetail'),
|
||||
duration: const Duration(seconds: 5),
|
||||
backgroundColor: appColors.error,
|
||||
),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
stageNotifierDisposed = true;
|
||||
stageNotifier.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// エラー文字列から HTTP ステータスコードや既知キーワードを抽出して表示用の短い補足を返す。
|
||||
/// ユーザーが画面を見たときに「どのエラーか」を把握できるようにするため。
|
||||
String _extractErrorCode(String err) {
|
||||
final patterns = {
|
||||
RegExp(r'\b(4\d{2}|5\d{2})\b'): (Match m) => ' [${m.group(0)}]',
|
||||
RegExp(r'API_KEY_INVALID|PERMISSION_DENIED'): (_) => ' [key?]',
|
||||
RegExp(r'RESOURCE_EXHAUSTED'): (_) => ' [quota]',
|
||||
RegExp(r'NOT_FOUND'): (_) => ' [model?]',
|
||||
RegExp(r'timeout', caseSensitive: false): (_) => ' [timeout]',
|
||||
};
|
||||
for (final entry in patterns.entries) {
|
||||
final m = entry.key.firstMatch(err);
|
||||
if (m != null) return entry.value(m);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/// さけのわ自動マッチング処理
|
||||
///
|
||||
/// 登録後にバックグラウンドで実行。
|
||||
|
|
|
|||
|
|
@ -358,6 +358,15 @@ class _PendingAnalysisScreenState extends ConsumerState<PendingAnalysisScreen> {
|
|||
width: 60,
|
||||
height: 60,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, __, ___) => Container(
|
||||
width: 60,
|
||||
height: 60,
|
||||
decoration: BoxDecoration(
|
||||
color: appColors.divider,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(LucideIcons.image, color: appColors.iconSubtle),
|
||||
),
|
||||
),
|
||||
)
|
||||
: Container(
|
||||
|
|
|
|||
|
|
@ -345,7 +345,13 @@ class _SakeDetailScreenState extends ConsumerState<SakeDetailScreen> {
|
|||
showDialog(context: context, barrierDismissible: false, builder: (context) => const AnalyzingDialog());
|
||||
|
||||
final geminiService = ref.read(geminiServiceProvider);
|
||||
final result = await geminiService.analyzeSakeLabel(existingPaths, forceRefresh: true);
|
||||
// 再解析専用メソッド: 前回の name/brand を渡してモデルに再考させる
|
||||
// temperature=0.3 で非決定論的にすることで hallucination の繰り返しを防ぐ
|
||||
final result = await geminiService.reanalyzeSakeLabel(
|
||||
existingPaths,
|
||||
previousName: _sake.displayData.displayName,
|
||||
previousBrand: _sake.displayData.displayBrewery,
|
||||
);
|
||||
|
||||
final newItem = _sake.copyWith(
|
||||
name: result.name ?? _sake.displayData.displayName,
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import '../providers/theme_provider.dart';
|
||||
import '../theme/app_colors.dart';
|
||||
import '../widgets/settings/display_settings_section.dart';
|
||||
import '../widgets/settings/other_settings_section.dart';
|
||||
import '../widgets/settings/backup_settings_section.dart';
|
||||
|
|
@ -22,7 +23,7 @@ class _ShopSettingsScreenState extends ConsumerState<ShopSettingsScreen> {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final userProfile = ref.watch(userProfileProvider);
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
final appColors = Theme.of(context).extension<AppColors>()!;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
|
|
@ -35,9 +36,9 @@ class _ShopSettingsScreenState extends ConsumerState<ShopSettingsScreen> {
|
|||
// Business Config Section
|
||||
_buildSectionHeader(context, '価格設定', LucideIcons.briefcase),
|
||||
Card(
|
||||
color: isDark ? const Color(0xFF1E1E1E) : null,
|
||||
color: appColors.surfaceElevated,
|
||||
child: ListTile(
|
||||
leading: Icon(LucideIcons.percent, color: isDark ? Colors.orange[300] : Theme.of(context).primaryColor),
|
||||
leading: Icon(LucideIcons.percent, color: appColors.iconAccent),
|
||||
title: const Text('基本掛率'),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
|
|
@ -45,15 +46,15 @@ class _ShopSettingsScreenState extends ConsumerState<ShopSettingsScreen> {
|
|||
Text('×', style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
color: isDark ? Colors.grey[400] : Colors.grey[600],
|
||||
color: appColors.textSecondary,
|
||||
)),
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? Colors.grey[800] : Colors.grey[100],
|
||||
color: appColors.surfaceSubtle,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: isDark ? Colors.grey[700]! : Colors.grey[300]!),
|
||||
border: Border.all(color: appColors.divider),
|
||||
),
|
||||
child: DropdownButton<double>(
|
||||
value: userProfile.defaultMarkup,
|
||||
|
|
@ -96,18 +97,18 @@ class _ShopSettingsScreenState extends ConsumerState<ShopSettingsScreen> {
|
|||
}
|
||||
|
||||
Widget _buildSectionHeader(BuildContext context, String title, IconData icon) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
final appColors = Theme.of(context).extension<AppColors>()!;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon, size: 20, color: isDark ? Colors.orange[300] : Theme.of(context).primaryColor),
|
||||
Icon(icon, size: 20, color: appColors.iconAccent),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: isDark ? Colors.grey[300] : Theme.of(context).primaryColor,
|
||||
color: appColors.textPrimary,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -11,6 +11,15 @@ import 'package:path_provider/path_provider.dart';
|
|||
import 'package:path/path.dart' as path;
|
||||
import '../models/sake_item.dart';
|
||||
|
||||
/// 復元前の安全バックアップ作成に失敗したことを示す例外。
|
||||
/// UI 側でユーザーに中断/続行を選ばせるために使用する。
|
||||
class PreRestoreBackupException implements Exception {
|
||||
const PreRestoreBackupException();
|
||||
|
||||
@override
|
||||
String toString() => 'PreRestoreBackupException: pre-restore safety backup failed';
|
||||
}
|
||||
|
||||
/// Google Driveへのバックアップ・復元を管理するサービス
|
||||
///
|
||||
/// 【主な機能】
|
||||
|
|
@ -325,8 +334,11 @@ class BackupService {
|
|||
|
||||
final driveApi = drive.DriveApi(authClient);
|
||||
|
||||
// 3. 現在のデータを退避
|
||||
await _createPreRestoreBackup();
|
||||
// 3. 現在のデータを退避(失敗したら呼び出し元に通知して中断させる)
|
||||
final preBackupOk = await _createPreRestoreBackup();
|
||||
if (!preBackupOk) {
|
||||
throw const PreRestoreBackupException();
|
||||
}
|
||||
|
||||
// 4. Google Driveからダウンロード
|
||||
final zipFile = await _downloadFromDrive(driveApi);
|
||||
|
|
@ -342,26 +354,55 @@ class BackupService {
|
|||
await zipFile.delete();
|
||||
|
||||
return success;
|
||||
} on PreRestoreBackupException {
|
||||
rethrow;
|
||||
} catch (error) {
|
||||
debugPrint('[RESTORE] Restore error: $error');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 復元前に現在のデータを退避
|
||||
Future<void> _createPreRestoreBackup() async {
|
||||
/// 復元前に現在のデータを退避する。成功したら true、失敗したら false を返す。
|
||||
Future<bool> _createPreRestoreBackup() async {
|
||||
try {
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final backupPath = path.join(tempDir.path, 'pre_restore_backup.zip');
|
||||
|
||||
final zipFile = await _createBackupZip();
|
||||
if (zipFile != null) {
|
||||
if (zipFile == null) {
|
||||
debugPrint('[RESTORE] Pre-restore backup: ZIP creation failed');
|
||||
return false;
|
||||
}
|
||||
await zipFile.copy(backupPath);
|
||||
await zipFile.delete();
|
||||
debugPrint('[RESTORE] Pre-restore backup saved: $backupPath');
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
debugPrint('[RESTORE] Pre-restore backup error: $error');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 安全バックアップをスキップして復元を強行する(ユーザーが警告を承諾した場合)。
|
||||
Future<bool> restoreBackupSkippingPreBackup() async {
|
||||
try {
|
||||
final account = _googleSignIn.currentUser;
|
||||
if (account == null) return false;
|
||||
|
||||
final authClient = await _googleSignIn.authenticatedClient();
|
||||
if (authClient == null) return false;
|
||||
|
||||
final driveApi = drive.DriveApi(authClient);
|
||||
|
||||
final zipFile = await _downloadFromDrive(driveApi);
|
||||
if (zipFile == null) return false;
|
||||
|
||||
final success = await _restoreFromZip(zipFile);
|
||||
await zipFile.delete();
|
||||
return success;
|
||||
} catch (error) {
|
||||
debugPrint('[RESTORE] Force restore error: $error');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -393,7 +434,14 @@ class BackupService {
|
|||
|
||||
// 3. ストリームをファイルに書き込み
|
||||
final sink = downloadFile.openWrite();
|
||||
await media.stream.pipe(sink);
|
||||
await media.stream.pipe(sink).timeout(
|
||||
const Duration(minutes: 3),
|
||||
onTimeout: () {
|
||||
sink.close();
|
||||
try { downloadFile.deleteSync(); } catch (_) {}
|
||||
throw TimeoutException('Backup download timed out after 3 minutes');
|
||||
},
|
||||
);
|
||||
|
||||
debugPrint('[RESTORE] Download complete: $downloadPath');
|
||||
return downloadFile;
|
||||
|
|
@ -484,7 +532,7 @@ class BackupService {
|
|||
isUserEdited: data['userData']['isUserEdited'] as bool,
|
||||
price: data['userData']['price'] 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
|
||||
? Map<String, int>.from(data['userData']['priceVariants'] as Map)
|
||||
: null,
|
||||
|
|
|
|||
|
|
@ -13,16 +13,280 @@ class GeminiService {
|
|||
// AI Proxy Server Configuration
|
||||
static final String _proxyUrl = Secrets.aiProxyAnalyzeUrl;
|
||||
|
||||
// レート制限対策? Proxy側で管理されているが、念のためクライアント側でも連打防止
|
||||
static DateTime? _lastApiCallTime;
|
||||
static const Duration _minApiInterval = Duration(seconds: 2);
|
||||
|
||||
GeminiService();
|
||||
|
||||
/// 画像リストから日本酒ラベルを解析
|
||||
Future<SakeAnalysisResult> analyzeSakeLabel(List<String> imagePaths, {bool forceRefresh = false}) async {
|
||||
// クライアント側プロンプトでスキーマの一貫性を保証
|
||||
const prompt = '''
|
||||
// ============================================================
|
||||
// Public API
|
||||
// ============================================================
|
||||
|
||||
/// 画像リストから日本酒ラベルを解析(2段階解析: OCR → フル解析)
|
||||
///
|
||||
/// [onStep1Complete]: Stage 1 完了時に呼ばれるコールバック。
|
||||
/// UI 側でダイアログのメッセージをステージ2用に切り替えるために使う。
|
||||
/// 直接APIモード(consumer APK)のみ有効。プロキシモードは1段階のまま。
|
||||
Future<SakeAnalysisResult> analyzeSakeLabel(
|
||||
List<String> imagePaths, {
|
||||
bool forceRefresh = false,
|
||||
VoidCallback? onStep1Complete,
|
||||
}) async {
|
||||
if (Secrets.useProxy) {
|
||||
return _callProxyApi(
|
||||
imagePaths: imagePaths,
|
||||
customPrompt: _mainAnalysisPrompt,
|
||||
forceRefresh: forceRefresh,
|
||||
);
|
||||
}
|
||||
return _runTwoStageAnalysis(
|
||||
imagePaths,
|
||||
forceRefresh: forceRefresh,
|
||||
onStep1Complete: onStep1Complete,
|
||||
);
|
||||
}
|
||||
|
||||
/// 再解析専用メソッド: 前回の結果を「疑わしい」として渡し、モデルに再考させる
|
||||
Future<SakeAnalysisResult> reanalyzeSakeLabel(
|
||||
List<String> imagePaths, {
|
||||
String? previousName,
|
||||
String? previousBrand,
|
||||
}) async {
|
||||
final prevNameStr = previousName != null ? '「$previousName」' : '不明';
|
||||
final prevBrandStr = previousBrand != null ? '「$previousBrand」' : '不明';
|
||||
|
||||
final challengePrompt = '''
|
||||
【再解析モード — 前回の回答を検証してください】
|
||||
|
||||
前回の解析では以下の結果が返されました:
|
||||
- name(銘柄名): $prevNameStr
|
||||
- brand(蔵元名): $prevBrandStr
|
||||
|
||||
この回答をユーザーが確認し、誤りの可能性があると指摘しました。
|
||||
添付画像を最初から丁寧に見直してください。
|
||||
|
||||
## 【必須確認ステップ】
|
||||
1. ラベル内の文字を1文字ずつ目で追ってください
|
||||
2. 前回の name=$prevNameStr の各漢字がラベルに実際に存在するか確認してください
|
||||
3. 存在しない文字が含まれていれば、ラベルに見えている文字のみに修正してください
|
||||
4. 「ラベルに N 文字しか見えないなら N 文字のみ返す」を厳守してください
|
||||
|
||||
## 【絶対ルール】name・brand・prefectureの読み取り(OCR厳守)
|
||||
- ラベルに印刷されている文字だけを一字一句そのまま出力してください
|
||||
- あなたが知っている「正式名称」への変換・補完は禁止
|
||||
- 【禁止例】「東魁」→「東魁盛」禁止 / 「男山」→「男山本醸造」禁止
|
||||
- ラベルに都道府県名がなければ prefecture は null(推測禁止)
|
||||
|
||||
## その他のフィールド(推定可)
|
||||
ラベル情報+日本酒の一般知識を使って推定してください。
|
||||
|
||||
## 出力形式
|
||||
以下のJSONのみ返す(説明文不要):
|
||||
{
|
||||
"name": "ラベルに写っている銘柄名(補完禁止)",
|
||||
"brand": "ラベルに写っている蔵元名(補完禁止)",
|
||||
"prefecture": "ラベルに書かれた都道府県名(なければnull)",
|
||||
"type": "特定名称(なければnull)",
|
||||
"description": "説明文(100文字程度)",
|
||||
"catchCopy": "20文字以内のキャッチコピー",
|
||||
"confidenceScore": 80,
|
||||
"flavorTags": ["フルーティー", "辛口"],
|
||||
"tasteStats": {"aroma":3,"sweetness":3,"acidity":3,"bitterness":3,"body":3},
|
||||
"alcoholContent": 15.0,
|
||||
"polishingRatio": 50,
|
||||
"sakeMeterValue": 3.0,
|
||||
"riceVariety": null,
|
||||
"yeast": null,
|
||||
"manufacturingYearMonth": null
|
||||
}
|
||||
''';
|
||||
|
||||
return _callDirectApi(
|
||||
imagePaths,
|
||||
challengePrompt,
|
||||
forceRefresh: true,
|
||||
temperature: 0.3,
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 2段階解析(直接APIモード専用)
|
||||
// ============================================================
|
||||
|
||||
/// Stage1(OCR) → Stage2(フル解析) の2段階フロー
|
||||
Future<SakeAnalysisResult> _runTwoStageAnalysis(
|
||||
List<String> imagePaths, {
|
||||
bool forceRefresh = false,
|
||||
VoidCallback? onStep1Complete,
|
||||
}) async {
|
||||
// Stage1実行前にキャッシュ確認(ヒットすれば API 呼び出しなし)
|
||||
if (!forceRefresh && imagePaths.isNotEmpty) {
|
||||
final imageHash = await AnalysisCacheService.computeCombinedHash(imagePaths);
|
||||
final cached = await AnalysisCacheService.getCached(imageHash);
|
||||
if (cached != null) {
|
||||
debugPrint('2-stage: cache hit, skipping API calls');
|
||||
return cached.asCached();
|
||||
}
|
||||
}
|
||||
|
||||
final apiKey = Secrets.geminiApiKey;
|
||||
if (apiKey.isEmpty) throw Exception('Gemini API Key is missing. Please set GEMINI_API_KEY.');
|
||||
|
||||
// 画像を一度だけ読み込み、Stage1/2で共用(ファイルI/O節約)
|
||||
final imageParts = <DataPart>[];
|
||||
for (final path in imagePaths) {
|
||||
final bytes = await File(path).readAsBytes();
|
||||
imageParts.add(DataPart('image/jpeg', bytes));
|
||||
debugPrint('Loaded image for 2-stage: ${(bytes.length / 1024).toStringAsFixed(1)}KB');
|
||||
}
|
||||
|
||||
// --- Stage 1: OCR専念(30秒タイムアウト・失敗時は1段階フォールバック)---
|
||||
Map<String, String?> ocr;
|
||||
try {
|
||||
ocr = await _performOcrStep(apiKey, imageParts);
|
||||
debugPrint('Stage1 OCR: name="${ocr['name']}" brand="${ocr['brand']}" pref="${ocr['prefecture']}"');
|
||||
} catch (e) {
|
||||
debugPrint('Stage1 OCR failed ($e), falling back to single-stage');
|
||||
return _callDirectApi(imagePaths, null, forceRefresh: forceRefresh);
|
||||
}
|
||||
|
||||
// Stage1 で name/brand が両方 null = ラベルを読めなかった → 2段階の意味なし
|
||||
if (ocr['name'] == null && ocr['brand'] == null) {
|
||||
debugPrint('Stage1 returned no text, falling back to single-stage');
|
||||
return _callDirectApi(imagePaths, null, forceRefresh: forceRefresh);
|
||||
}
|
||||
|
||||
// Stage 1 完了を UI に通知(AnalyzingDialog のメッセージを Stage2 用に切り替え)
|
||||
onStep1Complete?.call();
|
||||
|
||||
// --- Stage 2: OCR結果を制約として渡し、残りフィールドを推定 ---
|
||||
// _callDirectApi は内部でキャッシュ保存・リトライを行う(Stage2 も同じ堅牢性を持つ)
|
||||
// forceRefresh=false で呼ぶと内部でキャッシュ再チェックが走るが、
|
||||
// 上の確認でミス済みのため実害なし(ハッシュ計算のみ)
|
||||
final stage2Prompt = _buildStage2Prompt(ocr);
|
||||
return _callDirectApi(imagePaths, stage2Prompt, forceRefresh: forceRefresh);
|
||||
}
|
||||
|
||||
/// Stage 1: ラベルのOCRのみ実行(name / brand / prefecture を確定させる)
|
||||
///
|
||||
/// 軽量プロンプトで素早く文字起こし。補完・変換は完全禁止。
|
||||
/// 失敗時は呼び出し元がフォールバックを担当するため、ここでは rethrow。
|
||||
Future<Map<String, String?>> _performOcrStep(
|
||||
String apiKey,
|
||||
List<DataPart> imageParts,
|
||||
) async {
|
||||
const ocrPrompt = '''
|
||||
日本酒ラベルの画像から、銘柄名・蔵元名・都道府県名の3つだけをOCRしてください。
|
||||
|
||||
【絶対ルール】
|
||||
- ラベルに印刷された文字だけを一字一句そのまま出力する
|
||||
- 補完・変換・拡張は厳禁(例: 「東魁」→「東魁盛」禁止)
|
||||
- ラベルにN文字しかなければN文字のみ出力する
|
||||
|
||||
以下のJSONのみ返す(説明文不要):
|
||||
{"name": "銘柄名", "brand": "蔵元名", "prefecture": "都道府県名またはnull"}
|
||||
''';
|
||||
|
||||
final model = GenerativeModel(
|
||||
model: 'gemini-2.5-flash',
|
||||
apiKey: apiKey,
|
||||
systemInstruction: Content.system(
|
||||
'あなたはOCR専用システムです。ラベルの文字を一字一句正確に書き起こすだけです。'
|
||||
'銘柄名の補完・変換・拡張は厳禁。見えている文字数と出力文字数を一致させること。',
|
||||
),
|
||||
generationConfig: GenerationConfig(
|
||||
responseMimeType: 'application/json',
|
||||
temperature: 0,
|
||||
),
|
||||
);
|
||||
|
||||
final parts = <Part>[TextPart(ocrPrompt), ...imageParts];
|
||||
final response = await model
|
||||
.generateContent([Content.multi(parts)])
|
||||
.timeout(const Duration(seconds: 30));
|
||||
|
||||
final jsonStr = response.text;
|
||||
if (jsonStr == null || jsonStr.isEmpty) {
|
||||
throw Exception('Stage1: empty response');
|
||||
}
|
||||
|
||||
final map = jsonDecode(jsonStr) as Map<String, dynamic>;
|
||||
return {
|
||||
'name': map['name'] as String?,
|
||||
'brand': map['brand'] as String?,
|
||||
'prefecture': map['prefecture'] as String?,
|
||||
};
|
||||
}
|
||||
|
||||
/// Stage 2 用プロンプト: Stage 1 の OCR 結果を「確定済み制約」として埋め込む
|
||||
///
|
||||
/// Gemini は name/brand/prefecture をそのまま出力し、
|
||||
/// 残りフィールドのみ推定に集中することで hallucination を低減する。
|
||||
String _buildStage2Prompt(Map<String, String?> ocr) {
|
||||
final name = ocr['name'];
|
||||
final brand = ocr['brand'];
|
||||
final prefecture = ocr['prefecture'];
|
||||
|
||||
final nameConstraint = name != null ? '「$name」(確定済み — 変更禁止)' : 'null(確定済み)';
|
||||
final brandConstraint = brand != null ? '「$brand」(確定済み — 変更禁止)' : 'null(確定済み)';
|
||||
final prefConstraint = prefecture != null ? '「$prefecture」(確定済み — 変更禁止)' : 'null(確定済み)';
|
||||
|
||||
final nameJson = name != null ? jsonEncode(name) : 'null';
|
||||
final brandJson = brand != null ? jsonEncode(brand) : 'null';
|
||||
final prefJson = prefecture != null ? jsonEncode(prefecture) : 'null';
|
||||
|
||||
return '''
|
||||
あなたは日本酒ラベル解析の専門家です。
|
||||
|
||||
【ステップ1のOCR結果 — 以下3フィールドは変更厳禁】
|
||||
別ステップで画像から厳密にOCRした確定結果です。あなたの知識で書き換えることは絶対に禁止です。
|
||||
- name: $nameConstraint
|
||||
- brand: $brandConstraint
|
||||
- prefecture: $prefConstraint
|
||||
|
||||
上記3フィールドをそのままJSONに含め、残りのフィールドをラベル情報と日本酒知識から推定してください。
|
||||
|
||||
## 推定フィールド(ラベル+一般知識から推定可)
|
||||
- type: ラベルに書かれた特定名称(純米大吟醸など)。なければ null
|
||||
- description: ラベル情報と type から推定した味・特徴の説明(100文字程度)
|
||||
- catchCopy: 20文字以内のキャッチコピー
|
||||
- flavorTags: 味のタグ(フルーティー・辛口・華やか など)
|
||||
- tasteStats: 1〜5の整数。不明なら 3
|
||||
- alcoholContent: ラベルに記載があれば読む。なければ type から推定
|
||||
- polishingRatio: ラベルに記載があれば読む。なければ type から推定
|
||||
- sakeMeterValue: ラベルに記載があれば読む。なければ推定
|
||||
- riceVariety: ラベルに記載があれば読む。なければ null
|
||||
- yeast: ラベルに記載があれば読む。なければ null
|
||||
- manufacturingYearMonth: ラベルに記載があれば読む。なければ null
|
||||
- confidenceScore: 画像の鮮明度・情報量から 0〜100 で評価
|
||||
|
||||
## 出力形式
|
||||
以下のJSONのみ返す(説明文不要):
|
||||
{
|
||||
"name": $nameJson,
|
||||
"brand": $brandJson,
|
||||
"prefecture": $prefJson,
|
||||
"type": "特定名称(なければnull)",
|
||||
"description": "説明文(100文字程度)",
|
||||
"catchCopy": "20文字以内のキャッチコピー",
|
||||
"confidenceScore": 80,
|
||||
"flavorTags": ["フルーティー", "辛口"],
|
||||
"tasteStats": {"aroma":3,"sweetness":3,"acidity":3,"bitterness":3,"body":3},
|
||||
"alcoholContent": 15.0,
|
||||
"polishingRatio": 50,
|
||||
"sakeMeterValue": 3.0,
|
||||
"riceVariety": null,
|
||||
"yeast": null,
|
||||
"manufacturingYearMonth": null
|
||||
}
|
||||
''';
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 既存の1段階プロンプト(プロキシモード・フォールバック用)
|
||||
// ============================================================
|
||||
|
||||
static const String _mainAnalysisPrompt = '''
|
||||
あなたは日本酒ラベル解析の専門家です。
|
||||
添付画像から情報を読み取り、以下のJSONを返してください。
|
||||
|
||||
|
|
@ -86,25 +350,15 @@ name・brand を出力する直前に以下を確認してください:
|
|||
}
|
||||
''';
|
||||
|
||||
return _callProxyApi(
|
||||
imagePaths: imagePaths,
|
||||
customPrompt: prompt, // Override server default
|
||||
forceRefresh: forceRefresh,
|
||||
);
|
||||
}
|
||||
// ============================================================
|
||||
// プロキシ経由APIコール(iOSビルド用 / USE_PROXY=true 時)
|
||||
// ============================================================
|
||||
|
||||
/// 共通実装: ProxyへのAPIコール
|
||||
Future<SakeAnalysisResult> _callProxyApi({
|
||||
required List<String> imagePaths,
|
||||
String? customPrompt,
|
||||
bool forceRefresh = false,
|
||||
}) async {
|
||||
// Check Mode: Direct vs Proxy
|
||||
if (!Secrets.useProxy) {
|
||||
debugPrint('Direct Cloud Mode: Connecting to Gemini API directly...');
|
||||
return _callDirectApi(imagePaths, customPrompt, forceRefresh: forceRefresh);
|
||||
}
|
||||
|
||||
// 1. キャッシュチェック(forceRefresh=false のときのみ)
|
||||
if (!forceRefresh && imagePaths.isNotEmpty) {
|
||||
final imageHash = await AnalysisCacheService.computeCombinedHash(imagePaths);
|
||||
|
|
@ -125,21 +379,20 @@ name・brand を出力する直前に以下を確認してください:
|
|||
}
|
||||
_lastApiCallTime = DateTime.now();
|
||||
|
||||
// 2. 画像をBase64変換(撮影時に圧縮済み)
|
||||
// 3. 画像をBase64変換(撮影時に圧縮済み)
|
||||
List<String> base64Images = [];
|
||||
for (final path in imagePaths) {
|
||||
// Read already-compressed images directly (compressed at capture time)
|
||||
final bytes = await File(path).readAsBytes();
|
||||
final base64String = base64Encode(bytes);
|
||||
base64Images.add(base64String);
|
||||
debugPrint('Encoded processed image: ${(bytes.length / 1024).toStringAsFixed(1)}KB');
|
||||
}
|
||||
|
||||
// 3. デバイスID取得
|
||||
// 4. デバイスID取得
|
||||
final deviceId = await DeviceService.getDeviceId();
|
||||
if (kDebugMode) debugPrint('Device ID: $deviceId');
|
||||
|
||||
// 4. リクエスト作成
|
||||
// 5. リクエスト作成
|
||||
final requestBody = jsonEncode({
|
||||
"device_id": deviceId,
|
||||
"images": base64Images,
|
||||
|
|
@ -148,7 +401,7 @@ name・brand を出力する直前に以下を確認してください:
|
|||
|
||||
debugPrint('Calling Proxy: $_proxyUrl');
|
||||
|
||||
// 5. 送信(Bearer Token認証付き)
|
||||
// 6. 送信(Bearer Token認証付き)
|
||||
final headers = {
|
||||
"Content-Type": "application/json",
|
||||
if (Secrets.proxyAuthToken.isNotEmpty)
|
||||
|
|
@ -158,18 +411,16 @@ name・brand を出力する直前に以下を確認してください:
|
|||
Uri.parse(_proxyUrl),
|
||||
headers: headers,
|
||||
body: requestBody,
|
||||
).timeout(const Duration(seconds: 60)); // 拡張: 60秒 (画像サイズ最適化済み)
|
||||
).timeout(const Duration(seconds: 60));
|
||||
|
||||
// 6. レスポンス処理
|
||||
// 7. レスポンス処理
|
||||
if (response.statusCode == 200) {
|
||||
// 成功時のレスポンス形式: { "success": true, "data": {...}, "usage": {...} }
|
||||
final jsonResponse = jsonDecode(utf8.decode(response.bodyBytes));
|
||||
|
||||
if (jsonResponse['success'] == true) {
|
||||
final data = jsonResponse['data'];
|
||||
if (data == null) throw Exception("サーバーからのデータが空です");
|
||||
|
||||
// 使用状況ログ
|
||||
if (jsonResponse['usage'] != null) {
|
||||
final usage = jsonResponse['usage'];
|
||||
debugPrint('API Usage: ${usage['today']}/${usage['limit']}');
|
||||
|
|
@ -177,9 +428,6 @@ name・brand を出力する直前に以下を確認してください:
|
|||
|
||||
final result = SakeAnalysisResult.fromJson(data);
|
||||
|
||||
// tasteStats の補完・バリデーションは SakeAnalysisResult.fromJson 内で実施済み
|
||||
|
||||
// キャッシュに保存(次回同一画像はAPI不使用)
|
||||
if (imagePaths.isNotEmpty) {
|
||||
final imageHash = await AnalysisCacheService.computeCombinedHash(imagePaths);
|
||||
await AnalysisCacheService.saveCache(imageHash, result);
|
||||
|
|
@ -192,11 +440,9 @@ name・brand を出力する直前に以下を確認してください:
|
|||
|
||||
return result;
|
||||
} else {
|
||||
// Proxy側での論理エラー (レート制限超過など)
|
||||
throw Exception(jsonResponse['error'] ?? '不明なエラーが発生しました');
|
||||
}
|
||||
} else {
|
||||
// HTTPエラー
|
||||
if (kDebugMode) {
|
||||
debugPrint('Proxy Error: ${response.statusCode} ${response.body}');
|
||||
}
|
||||
|
|
@ -205,7 +451,6 @@ name・brand を出力する直前に以下を確認してください:
|
|||
|
||||
} catch (e) {
|
||||
debugPrint('Proxy Call Failed: $e');
|
||||
// エラーメッセージを整形
|
||||
final errorMsg = e.toString().toLowerCase();
|
||||
if (errorMsg.contains('limit') || errorMsg.contains('上限')) {
|
||||
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 {
|
||||
// 1. キャッシュチェック(同じ画像なら即座に返す)
|
||||
// forceRefresh=trueの場合はキャッシュをスキップ
|
||||
// ============================================================
|
||||
// 直接APIコール(consumer APK 用 / USE_PROXY=false 時)
|
||||
// ============================================================
|
||||
|
||||
Future<SakeAnalysisResult> _callDirectApi(
|
||||
List<String> imagePaths,
|
||||
String? customPrompt, {
|
||||
bool forceRefresh = false,
|
||||
double temperature = 0,
|
||||
}) async {
|
||||
if (!forceRefresh && imagePaths.isNotEmpty) {
|
||||
final imageHash = await AnalysisCacheService.computeCombinedHash(imagePaths);
|
||||
final cached = await AnalysisCacheService.getCached(imageHash);
|
||||
|
|
@ -227,65 +478,22 @@ name・brand を出力する直前に以下を確認してください:
|
|||
}
|
||||
}
|
||||
|
||||
// 2. API Key確認
|
||||
final apiKey = Secrets.geminiApiKey;
|
||||
if (apiKey.isEmpty) {
|
||||
throw Exception('Gemini API Key is missing. Please set GEMINI_API_KEY.');
|
||||
}
|
||||
|
||||
// モデル候補: 503/UNAVAILABLE 時にフォールバック
|
||||
// NOTE: Google は予告なしでモデルを廃止することがある。定期的に動作確認を行うこと。
|
||||
// Phase 2(プロキシ移行)後はサーバー側から設定を取得する設計に変更する予定。
|
||||
const primaryModel = 'gemini-2.5-flash';
|
||||
const fallbackModel = 'gemini-2.0-flash';
|
||||
const fallbackModel = 'gemini-2.5-flash-lite';
|
||||
|
||||
// customPrompt は analyzeSakeLabel から常に渡される。null になるケースは通常存在しない。
|
||||
final promptText = customPrompt ?? '''
|
||||
あなたは日本酒ラベル解析の専門家です。
|
||||
添付画像から情報を読み取り、以下のJSONを返してください。
|
||||
final promptText = customPrompt ?? _mainAnalysisPrompt;
|
||||
|
||||
## 【絶対ルール】name・brand・prefectureの読み取り(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)];
|
||||
for (var path in imagePaths) {
|
||||
final bytes = await File(path).readAsBytes();
|
||||
contentParts.add(DataPart('image/jpeg', bytes));
|
||||
}
|
||||
|
||||
// 503 時: リトライ(指数バックオフ)→ フォールバックモデル
|
||||
const maxRetries = 3;
|
||||
final modelsToTry = [primaryModel, primaryModel, primaryModel, fallbackModel];
|
||||
|
||||
|
|
@ -316,7 +524,7 @@ name・brand を出力する直前に以下を確認してください:
|
|||
),
|
||||
generationConfig: GenerationConfig(
|
||||
responseMimeType: 'application/json',
|
||||
temperature: 0,
|
||||
temperature: temperature,
|
||||
),
|
||||
);
|
||||
|
||||
|
|
@ -330,11 +538,9 @@ name・brand を出力する直前に以下を確認してください:
|
|||
final jsonMap = jsonDecode(jsonString);
|
||||
final result = SakeAnalysisResult.fromJson(jsonMap);
|
||||
|
||||
// 3. キャッシュに保存(次回は即座に返せる)
|
||||
if (imagePaths.isNotEmpty) {
|
||||
final imageHash = await AnalysisCacheService.computeCombinedHash(imagePaths);
|
||||
await AnalysisCacheService.saveCache(imageHash, result);
|
||||
// 4. 銘柄名インデックスに登録(forceRefresh 時は誤認識結果を上書き)
|
||||
await AnalysisCacheService.registerBrandIndex(
|
||||
result.name,
|
||||
imageHash,
|
||||
|
|
@ -351,22 +557,20 @@ name・brand を出力する直前に以下を確認してください:
|
|||
debugPrint('Direct API Error (attempt $attempt, model: $modelName): $e');
|
||||
|
||||
if (isLastAttempt || !is503) {
|
||||
// 最終試行 or 503以外のエラーはそのまま投げる
|
||||
if (is503) {
|
||||
throw const GeminiCongestionException();
|
||||
}
|
||||
if (is503) throw const GeminiCongestionException();
|
||||
throw Exception('AI解析エラー(Direct): $e');
|
||||
}
|
||||
// 503 → 次のリトライへ
|
||||
}
|
||||
}
|
||||
|
||||
// ここには到達しない
|
||||
throw Exception('AI解析に失敗しました。再試行してください。');
|
||||
}
|
||||
}
|
||||
|
||||
// Analysis Result Model
|
||||
// ============================================================
|
||||
// Data Models
|
||||
// ============================================================
|
||||
|
||||
class SakeAnalysisResult {
|
||||
final String? name;
|
||||
final String? brand;
|
||||
|
|
@ -378,7 +582,6 @@ class SakeAnalysisResult {
|
|||
final List<String> flavorTags;
|
||||
final Map<String, int> tasteStats;
|
||||
|
||||
// New Fields
|
||||
final double? alcoholContent;
|
||||
final int? polishingRatio;
|
||||
final double? sakeMeterValue;
|
||||
|
|
@ -387,7 +590,6 @@ class SakeAnalysisResult {
|
|||
final String? manufacturingYearMonth;
|
||||
|
||||
/// キャッシュから返された結果かどうか(EXP付与・新規登録の判定に使用)
|
||||
/// JSON には含まない(キャッシュ保存・復元時は常に false)
|
||||
final bool isFromCache;
|
||||
|
||||
SakeAnalysisResult({
|
||||
|
|
@ -409,7 +611,6 @@ class SakeAnalysisResult {
|
|||
this.isFromCache = false,
|
||||
});
|
||||
|
||||
/// キャッシュヒット用コピー
|
||||
SakeAnalysisResult asCached() => SakeAnalysisResult(
|
||||
name: name, brand: brand, prefecture: prefecture, type: type,
|
||||
description: description, catchCopy: catchCopy, confidenceScore: confidenceScore,
|
||||
|
|
@ -421,7 +622,6 @@ class SakeAnalysisResult {
|
|||
);
|
||||
|
||||
factory SakeAnalysisResult.fromJson(Map<String, dynamic> json) {
|
||||
// tasteStats: 欠損キーを 3 で補完、範囲外(1〜5)をクランプ
|
||||
const requiredStatKeys = ['aroma', 'sweetness', 'acidity', 'bitterness', 'body'];
|
||||
Map<String, int> stats = {};
|
||||
if (json['tasteStats'] is Map) {
|
||||
|
|
@ -454,7 +654,6 @@ class SakeAnalysisResult {
|
|||
);
|
||||
}
|
||||
|
||||
/// JSON形式に変換(キャッシュ保存用)
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'name': name,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'device_service.dart';
|
||||
|
|
@ -22,43 +23,81 @@ enum LicenseStatus {
|
|||
|
||||
/// ライセンス管理サービス
|
||||
///
|
||||
/// ## ストレージ方針
|
||||
/// - ライセンスキー本体: flutter_secure_storage(暗号化)
|
||||
/// - 状態キャッシュ: SharedPreferences(平文でもリスクなし)
|
||||
///
|
||||
/// ## 状態管理の優先順位
|
||||
/// 1. オンライン時: VPSに問い合わせた結果を使用 + SharedPreferencesにキャッシュ
|
||||
/// 2. オフライン時: SharedPreferencesのキャッシュを使用 (Pro状態を維持)
|
||||
/// 1. 起動時: SecureStorage のキャッシュを即時返却(ちらつき防止)
|
||||
/// 2. バックグラウンド: VPS で再検証し、差異があれば状態を更新
|
||||
/// 3. オフライン時: SharedPreferences のキャッシュを維持
|
||||
///
|
||||
/// ## ライセンスキー形式
|
||||
/// PONSHU-XXXX-XXXX-XXXX (6バイト = 12hex文字, 大文字)
|
||||
class LicenseService {
|
||||
static const _prefLicenseKey = 'ponshu_license_key';
|
||||
static const _secureKeyName = 'ponshu_license_key';
|
||||
static const _prefCachedStatus = 'ponshu_license_status_cache';
|
||||
static const _prefCachedAt = 'ponshu_license_cached_at';
|
||||
static const _cacheValidSeconds = 24 * 60 * 60; // 24時間キャッシュ有効
|
||||
static const _prefMigratedV1 = 'ponshu_license_migrated_v1';
|
||||
static const _cacheValidSeconds = 24 * 60 * 60; // 24時間
|
||||
|
||||
static const _storage = FlutterSecureStorage(
|
||||
aOptions: AndroidOptions(encryptedSharedPreferences: true),
|
||||
);
|
||||
|
||||
// ========== Migration ==========
|
||||
|
||||
/// SharedPreferences → flutter_secure_storage の一回限りのマイグレーション
|
||||
static Future<void> _migrateIfNeeded() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
if (prefs.getBool(_prefMigratedV1) == true) return;
|
||||
|
||||
// 旧ストレージにキーがあれば移行して削除
|
||||
final oldValue = prefs.getString(_secureKeyName);
|
||||
if (oldValue != null && oldValue.isNotEmpty) {
|
||||
await _storage.write(key: _secureKeyName, value: oldValue);
|
||||
await prefs.remove(_secureKeyName);
|
||||
debugPrint('[License] Migrated license key to secure storage.');
|
||||
}
|
||||
await prefs.setBool(_prefMigratedV1, true);
|
||||
}
|
||||
|
||||
// ========== Public API ==========
|
||||
|
||||
/// アプリ起動時に呼ぶ: ライセンス状態を確認して返す
|
||||
static Future<LicenseStatus> checkStatus() async {
|
||||
/// キャッシュのみを即時返却(サーバー問い合わせなし)
|
||||
///
|
||||
/// 起動時のちらつき防止用。main() で await してから runApp() に渡す。
|
||||
static Future<LicenseStatus> getCachedStatusOnly() async {
|
||||
await _migrateIfNeeded();
|
||||
final savedKey = await _storage.read(key: _secureKeyName) ?? '';
|
||||
if (savedKey.isEmpty) return LicenseStatus.free;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final savedKey = prefs.getString(_prefLicenseKey) ?? '';
|
||||
return _getCachedStatus(prefs);
|
||||
}
|
||||
|
||||
/// アプリ起動時(バックグラウンド): VPS でライセンス状態を検証して返す
|
||||
static Future<LicenseStatus> checkStatus() async {
|
||||
await _migrateIfNeeded();
|
||||
final savedKey = await _storage.read(key: _secureKeyName) ?? '';
|
||||
|
||||
// ライセンスキーが保存済み → サーバーで検証
|
||||
if (savedKey.isNotEmpty) {
|
||||
try {
|
||||
final status = await _validateKeyWithServer(savedKey);
|
||||
if (status == LicenseStatus.offline) {
|
||||
// ネットワーク不通: キャッシュを上書きせずに返す
|
||||
debugPrint('[License] Server unreachable, using cache');
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return _getCachedStatus(prefs);
|
||||
}
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await _cacheStatus(prefs, status);
|
||||
return status;
|
||||
} catch (e) {
|
||||
debugPrint('[License] Server unreachable, using cache: $e');
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return _getCachedStatus(prefs);
|
||||
}
|
||||
}
|
||||
|
||||
// ライセンスキーなし → 無料版
|
||||
return LicenseStatus.free;
|
||||
}
|
||||
|
||||
|
|
@ -76,8 +115,8 @@ class LicenseService {
|
|||
final status = await _validateKeyWithServer(key);
|
||||
|
||||
if (status == LicenseStatus.pro) {
|
||||
await _storage.write(key: _secureKeyName, value: key);
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_prefLicenseKey, key);
|
||||
await _cacheStatus(prefs, LicenseStatus.pro);
|
||||
debugPrint('[License] Activated successfully.');
|
||||
return (success: true, message: '');
|
||||
|
|
@ -96,14 +135,14 @@ class LicenseService {
|
|||
|
||||
/// ライセンスキーがローカルに保存されているか
|
||||
static Future<bool> hasLicenseKey() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return (prefs.getString(_prefLicenseKey) ?? '').isNotEmpty;
|
||||
await _migrateIfNeeded();
|
||||
return ((await _storage.read(key: _secureKeyName)) ?? '').isNotEmpty;
|
||||
}
|
||||
|
||||
/// ライセンスをリセット(デバッグ用)
|
||||
static Future<void> reset() async {
|
||||
await _storage.delete(key: _secureKeyName);
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove(_prefLicenseKey);
|
||||
await prefs.remove(_prefCachedStatus);
|
||||
await prefs.remove(_prefCachedAt);
|
||||
debugPrint('[License] Reset complete.');
|
||||
|
|
@ -128,7 +167,11 @@ class LicenseService {
|
|||
final data = jsonDecode(utf8.decode(response.bodyBytes)) as Map<String, dynamic>;
|
||||
|
||||
if (data['valid'] == true) return LicenseStatus.pro;
|
||||
if ((data['error'] as String? ?? '').contains('無効化')) return LicenseStatus.revoked;
|
||||
// revoked フィールド(boolean)を優先し、error メッセージ文字列にも対応
|
||||
if (data['revoked'] == true ||
|
||||
(data['error'] as String? ?? '').contains('無効化')) {
|
||||
return LicenseStatus.revoked;
|
||||
}
|
||||
return LicenseStatus.free;
|
||||
|
||||
} catch (e) {
|
||||
|
|
@ -148,14 +191,13 @@ class LicenseService {
|
|||
|
||||
if (cached == null) return LicenseStatus.free;
|
||||
|
||||
// オンライン時は _validateKeyWithServer が常に上書きするため、
|
||||
// オンライン時は checkStatus が常に上書きするため、
|
||||
// _getCachedStatus はオフライン時専用のフォールバックとして動作する。
|
||||
//
|
||||
// TTL 判定(_cacheValidSeconds = 24h):
|
||||
// - free / offline は期限切れで free にフォールバック
|
||||
// - free / offline: 期限切れで free にフォールバック
|
||||
// - pro : 購入者をオフライン時に締め出さないため永続扱い
|
||||
// - revoked: 不正防止を優先するため永続扱い
|
||||
// (将来 TTL を設けたい場合は isNoExpiryStatus を条件分岐ごと差し替える)
|
||||
if (cachedAt != null) {
|
||||
final age = DateTime.now().difference(DateTime.parse(cachedAt));
|
||||
final isNoExpiryStatus = cached == LicenseStatus.pro.name || cached == LicenseStatus.revoked.name;
|
||||
|
|
|
|||
|
|
@ -2,7 +2,12 @@ import 'package:flutter/material.dart';
|
|||
import 'dart:async';
|
||||
|
||||
class AnalyzingDialog extends StatefulWidget {
|
||||
const AnalyzingDialog({super.key});
|
||||
/// Stage 通知用 ValueNotifier。
|
||||
/// null のとき(プロキシモード・キャッシュヒット後など)は Stage1 メッセージのみを表示する。
|
||||
/// value が 2 に変わると Stage2 メッセージセットに切り替わる。
|
||||
final ValueNotifier<int>? stageNotifier;
|
||||
|
||||
const AnalyzingDialog({super.key, this.stageNotifier});
|
||||
|
||||
@override
|
||||
State<AnalyzingDialog> createState() => _AnalyzingDialogState();
|
||||
|
|
@ -10,32 +15,70 @@ class AnalyzingDialog extends StatefulWidget {
|
|||
|
||||
class _AnalyzingDialogState extends State<AnalyzingDialog> {
|
||||
int _messageIndex = 0;
|
||||
Timer? _timer;
|
||||
|
||||
final List<String> _messages = [
|
||||
'ラベルを読んでいます...',
|
||||
'銘柄を確認しています...',
|
||||
static const _stage1Messages = [
|
||||
'ラベルを読み取っています...',
|
||||
'文字を一字一句確認中...',
|
||||
];
|
||||
|
||||
static const _stage2Messages = [
|
||||
'この日本酒の個性を分析中...',
|
||||
'フレーバーチャートを描画しています...',
|
||||
'素敵なキャッチコピーを考えています...',
|
||||
];
|
||||
|
||||
List<String> get _currentMessages =>
|
||||
(_stage == 2) ? _stage2Messages : _stage1Messages;
|
||||
|
||||
int _stage = 1;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
widget.stageNotifier?.addListener(_onStageChanged);
|
||||
_startMessageRotation();
|
||||
}
|
||||
|
||||
void _startMessageRotation() {
|
||||
Future.delayed(const Duration(milliseconds: 1500), () {
|
||||
if (mounted && _messageIndex < _messages.length - 1) {
|
||||
setState(() => _messageIndex++);
|
||||
void _onStageChanged() {
|
||||
final newStage = widget.stageNotifier?.value ?? 1;
|
||||
if (newStage != _stage) {
|
||||
_timer?.cancel();
|
||||
setState(() {
|
||||
_stage = newStage;
|
||||
_messageIndex = 0;
|
||||
});
|
||||
_startMessageRotation();
|
||||
}
|
||||
}
|
||||
|
||||
void _startMessageRotation() {
|
||||
_timer = Timer.periodic(const Duration(milliseconds: 1800), (timer) {
|
||||
if (!mounted) {
|
||||
timer.cancel();
|
||||
return;
|
||||
}
|
||||
final messages = _currentMessages;
|
||||
if (_messageIndex < messages.length - 1) {
|
||||
setState(() => _messageIndex++);
|
||||
} else {
|
||||
timer.cancel();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_timer?.cancel();
|
||||
widget.stageNotifier?.removeListener(_onStageChanged);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final messages = _currentMessages;
|
||||
final safeIndex = _messageIndex.clamp(0, messages.length - 1);
|
||||
|
||||
return Dialog(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
|
|
@ -45,10 +88,19 @@ class _AnalyzingDialogState extends State<AnalyzingDialog> {
|
|||
const CircularProgressIndicator(),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
_messages[_messageIndex],
|
||||
messages[safeIndex],
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
if (widget.stageNotifier != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'ステップ $_stage / 2',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.4),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -272,6 +272,7 @@ class _SakeDetailSpecsState extends State<SakeDetailSpecs> {
|
|||
_isEditing,
|
||||
suffixIcon: LucideIcons.calendar,
|
||||
onSuffixTap: () => _showDatePicker(context),
|
||||
helperText: '例: 2023-10',
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -303,10 +304,11 @@ class _SakeDetailSpecsState extends State<SakeDetailSpecs> {
|
|||
void _showDatePicker(BuildContext context) {
|
||||
if (!_isEditing) return;
|
||||
|
||||
// Parse current value or use now
|
||||
// Parse current value or use now (AI出力 "2023.10" とユーザー入力 "2023-10" の両形式に対応)
|
||||
DateTime initialDate = DateTime.now();
|
||||
try {
|
||||
final parts = _manufacturingController.text.split('-');
|
||||
final normalized = _manufacturingController.text.replaceAll('.', '-');
|
||||
final parts = normalized.split('-');
|
||||
if (parts.length >= 2) {
|
||||
final year = int.parse(parts[0]);
|
||||
final month = int.parse(parts[1]);
|
||||
|
|
|
|||
|
|
@ -1,9 +1,12 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import '../../providers/sake_list_provider.dart';
|
||||
import '../../providers/theme_provider.dart';
|
||||
import '../../services/backup_service.dart';
|
||||
import '../../theme/app_colors.dart';
|
||||
|
||||
class BackupSettingsSection extends StatefulWidget {
|
||||
class BackupSettingsSection extends ConsumerStatefulWidget {
|
||||
final String title;
|
||||
|
||||
const BackupSettingsSection({
|
||||
|
|
@ -12,12 +15,12 @@ class BackupSettingsSection extends StatefulWidget {
|
|||
});
|
||||
|
||||
@override
|
||||
State<BackupSettingsSection> createState() => _BackupSettingsSectionState();
|
||||
ConsumerState<BackupSettingsSection> createState() => _BackupSettingsSectionState();
|
||||
}
|
||||
|
||||
enum _BackupState { idle, signingIn, signingOut, backingUp, restoring }
|
||||
|
||||
class _BackupSettingsSectionState extends State<BackupSettingsSection> {
|
||||
class _BackupSettingsSectionState extends ConsumerState<BackupSettingsSection> {
|
||||
final BackupService _backupService = BackupService();
|
||||
_BackupState _state = _BackupState.idle;
|
||||
|
||||
|
|
@ -28,7 +31,11 @@ enum _BackupState { idle, signingIn, signingOut, backingUp, restoring }
|
|||
}
|
||||
|
||||
Future<void> _initBackupService() async {
|
||||
try {
|
||||
await _backupService.init();
|
||||
} catch (e) {
|
||||
debugPrint('[Backup] Init error (silent sign-in failed): $e');
|
||||
}
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
}
|
||||
|
|
@ -132,7 +139,7 @@ enum _BackupState { idle, signingIn, signingOut, backingUp, restoring }
|
|||
Future<void> _restoreBackup() async {
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final appColors = Theme.of(context).extension<AppColors>()!;
|
||||
// Note: hasBackup check is async
|
||||
|
||||
final hasBackup = await _backupService.hasBackupOnDrive();
|
||||
if (!hasBackup) {
|
||||
if (mounted) {
|
||||
|
|
@ -173,11 +180,29 @@ enum _BackupState { idle, signingIn, signingOut, backingUp, restoring }
|
|||
),
|
||||
);
|
||||
|
||||
if (confirmed == true && mounted) {
|
||||
if (confirmed != true || !mounted) return;
|
||||
await _executeRestore(forceSkipPreBackup: false);
|
||||
}
|
||||
|
||||
/// 実際の復元処理。PreRestoreBackupException を受けた場合はダイアログで続行確認する。
|
||||
Future<void> _executeRestore({required bool forceSkipPreBackup}) async {
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final appColors = Theme.of(context).extension<AppColors>()!;
|
||||
|
||||
setState(() => _state = _BackupState.restoring);
|
||||
final success = await _backupService.restoreBackup();
|
||||
|
||||
try {
|
||||
final success = forceSkipPreBackup
|
||||
? await _backupService.restoreBackupSkippingPreBackup()
|
||||
: await _backupService.restoreBackup();
|
||||
|
||||
if (mounted) {
|
||||
setState(() => _state = _BackupState.idle);
|
||||
if (success) {
|
||||
ref.invalidate(rawSakeListItemsProvider);
|
||||
ref.invalidate(sakeSortOrderProvider);
|
||||
ref.invalidate(userProfileProvider);
|
||||
}
|
||||
messenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(success ? '復元が完了しました' : '復元に失敗しました'),
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
52
pubspec.lock
52
pubspec.lock
|
|
@ -515,6 +515,54 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.0"
|
||||
flutter_secure_storage:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_secure_storage
|
||||
sha256: "9cad52d75ebc511adfae3d447d5d13da15a55a92c9410e50f67335b6d21d16ea"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "9.2.4"
|
||||
flutter_secure_storage_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_linux
|
||||
sha256: be76c1d24a97d0b98f8b54bce6b481a380a6590df992d0098f868ad54dc8f688
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.3"
|
||||
flutter_secure_storage_macos:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_macos
|
||||
sha256: "6c0a2795a2d1de26ae202a0d78527d163f4acbb11cde4c75c670f3a0fc064247"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.3"
|
||||
flutter_secure_storage_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_platform_interface
|
||||
sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.2"
|
||||
flutter_secure_storage_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_web
|
||||
sha256: f4ebff989b4f07b2656fb16b47852c0aab9fed9b4ec1c70103368337bc1886a9
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.1"
|
||||
flutter_secure_storage_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_windows
|
||||
sha256: b20b07cb5ed4ed74fc567b78a72936203f587eba460af1df11281c9326cd3709
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.2"
|
||||
flutter_speed_dial:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
|
@ -801,10 +849,10 @@ packages:
|
|||
dependency: transitive
|
||||
description:
|
||||
name: js
|
||||
sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc"
|
||||
sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.2"
|
||||
version: "0.6.7"
|
||||
json_annotation:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
|||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||
# In Windows, build-name is used as the major, minor, and patch parts
|
||||
# of the product and file versions while build-number is used as the build suffix.
|
||||
version: 1.0.45+52
|
||||
version: 1.0.49+56
|
||||
|
||||
environment:
|
||||
sdk: ^3.10.1
|
||||
|
|
@ -46,7 +46,7 @@ dependencies:
|
|||
device_info_plus: ^10.1.0
|
||||
http: ^1.2.0
|
||||
crypto: ^3.0.3 # SHA-256 ハッシュ計算用(キャッシュ)
|
||||
lucide_icons: ^0.257.0
|
||||
lucide_icons: 0.257.0
|
||||
reorderable_grid_view: ^2.2.5
|
||||
camera: ^0.11.3
|
||||
path_provider: ^2.1.5
|
||||
|
|
@ -61,6 +61,7 @@ dependencies:
|
|||
package_info_plus: ^8.1.2
|
||||
gal: ^2.3.0
|
||||
shared_preferences: ^2.5.4
|
||||
flutter_secure_storage: ^9.2.2
|
||||
|
||||
# Phase 9: Google Drive Backup
|
||||
googleapis: ^13.2.0
|
||||
|
|
|
|||
|
|
@ -1,20 +1,20 @@
|
|||
{
|
||||
"version": "v1.0.43",
|
||||
"name": "Ponshu Room 1.0.43 (2026-04-18)",
|
||||
"date": "2026-04-18",
|
||||
"version": "v1.0.49",
|
||||
"name": "Ponshu Room 1.0.49 (2026-04-23)",
|
||||
"date": "2026-04-23",
|
||||
"apks": {
|
||||
"maita": {
|
||||
"lite": {
|
||||
"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",
|
||||
"size_mb": 89
|
||||
"url": "https://posimai-lab.tail72e846.ts.net/mai/ponshu-room-lite/releases/download/v1.0.49/ponshu_room_consumer_maita.apk",
|
||||
"size_mb": 91
|
||||
}
|
||||
},
|
||||
"eiji": {
|
||||
"lite": {
|
||||
"filename": "ponshu_room_consumer_eiji.apk",
|
||||
"url": "https://posimai-lab.tail72e846.ts.net/mai/ponshu-room-lite/releases/download/v1.0.43/ponshu_room_consumer_eiji.apk",
|
||||
"size_mb": 89
|
||||
"url": "https://posimai-lab.tail72e846.ts.net/mai/ponshu-room-lite/releases/download/v1.0.49/ponshu_room_consumer_eiji.apk",
|
||||
"size_mb": 91
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
|
||||
#include <connectivity_plus/connectivity_plus_windows_plugin.h>
|
||||
#include <file_selector_windows/file_selector_windows.h>
|
||||
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
|
||||
#include <gal/gal_plugin_c_api.h>
|
||||
#include <printing/printing_plugin.h>
|
||||
#include <share_plus/share_plus_windows_plugin_c_api.h>
|
||||
|
|
@ -18,6 +19,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
|
|||
registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin"));
|
||||
FileSelectorWindowsRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("FileSelectorWindows"));
|
||||
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
|
||||
GalPluginCApiRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("GalPluginCApi"));
|
||||
PrintingPluginRegisterWithRegistrar(
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
connectivity_plus
|
||||
file_selector_windows
|
||||
flutter_secure_storage_windows
|
||||
gal
|
||||
printing
|
||||
share_plus
|
||||
|
|
|
|||
Loading…
Reference in New Issue