Compare commits

...

32 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
Ponshu Developer 1bf59e02cc fix(stability): エラーハンドリング強化・クラッシュ防止 (v1.0.45)
- backup_service: 復元ループを per-item try-catch に変更
  一件のJSONパース失敗でも他のアイテムが正常に復元されるよう修正
  rating/markup/score の num→double キャスト安全化も同時適用

- camera_analysis_mixin: cast<String>() を whereType<String>() に変更
  旧Hiveデータや型不一致でも新規登録がクラッシュしなくなる

- add_set_item_dialog: 空catchブロックにSnackBar通知を追加
  画像選択失敗時にユーザーへフィードバックを表示するよう修正

- Image.file() errorBuilder を6ファイルに追加
  sake_3d_carousel / sake_3d_carousel_with_reason / sake_search_delegate /
  sake_detail_sliver_app_bar (×2) / sake_photo_edit_modal /
  sakenowa_ranking_section — 画像ファイルが削除済みでも黒画面/クラッシュなし

- gemini_service: Device ID の debugPrint を kDebugMode ガードに変更

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 07:31:21 +09:00
Ponshu Developer 0fb4f6ea8b fix(ai): ハルシネーション防止を一般化・N文字ルール追加・6銘柄例示
- systemInstruction をOCR専門システムとして再定義(東魁限定から一般化)
- 「ラベルにN文字なら必ずN文字のみ出力」ルールを明示化
- 東魁/男山/白鹿/久保田/白鶴/松竹梅の6銘柄例を追加
- 出力前セルフチェック手順をプロンプトに追加
- menu_pricing_screen: SharedPreferences を async/await+try-catch に修正
- version: 1.0.43+50 → 1.0.44+51
- web: eiji/maita 向け consumer APK (v1.0.44) を配置・ダウンロードページ更新

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 00:26:13 +09:00
Ponshu Developer d72587ac19 security: .env.exampleの実APIキーをダミー値に差し替え
MAITA_API_KEY・EIJI_API_KEYに本物のGemini APIキーが入っていた。
ダミー値(AIzaSy_YOUR_GEMINI_KEY_HERE)に置換。
実キーはGoogle Cloud Consoleでローテーション要。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 00:07:38 +09:00
Ponshu Developer 1a84163654 fix: use_build_context_synchronously 解消
_reanalyze で nav / messenger を async gap 前にキャプチャするよう移動。
showDialog の context 引数を ignore 対象行に統合。
dart analyze: No issues found

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 08:20:42 +09:00
Ponshu Developer 2e770ff98d refactor: ハードコード色をAppColorsセマンティックカラーに置換
- pending_analysis_banner: Colors.orange.* → appColors.warning(グラデーション・影・バッジ)
- activity_stats: Colors.orange → appColors.warning(残回数少ない警告色・テキスト)
- scan_screen: Colors.grey → appColors.textTertiary(蔵元/産地テキスト)
- sake_no_match_state: Colors.grey[400/600] → appColors.textTertiary(空状態アイコン・テキスト)
- camera_screen: Colors.greenAccent → appColors.success(解析開始ボタン)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 08:18:48 +09:00
Ponshu Developer e7bb4e494c chore: update releases.json for v1.0.43
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 22:40:49 +09:00
Ponshu Developer 5bcacfffa3 chore: bump version to v1.0.43+50
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 14:26:18 +09:00
Ponshu Developer 9fba57621a fix: quotaLockout Provider化・色トークン整備・依存バージョン固定
- quotaLockoutProvider (NotifierProvider) を新規作成し、カメラ・詳細画面で共有
  - camera_analysis_mixin: quotaLockoutTime フィールドを削除、429時にProviderへ設定
  - camera_screen: ref.watch(quotaLockoutProvider) でシャッターボタンUI更新
  - sake_detail_screen: _quotaLockoutTime フィールドを削除、Providerに移行
  - 画面遷移後もロックアウト状態が保持されるP1バグを解消
- camera_screen: Colors.red/grey → appColors.error/textTertiary に置換
- camera_screen: ギャラリー保存SnackBarから例外文字列 $e を除去
- camera_screen: SnackBarAction textColor Colors.yellow → appColors.brandAccent
- pubspec.yaml: flutter_riverpod ^3.1.0, riverpod_annotation 3.0.0-dev.3,
  riverpod_generator 3.0.0-dev.11 を固定(バージョン未固定による意図しないアップグレードを防止)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 14:18:27 +09:00
Ponshu Developer dd9b814174 refactor: TextEditingControllerリーク解消・エラー正規化・デザイントークン整備
- sake_detail_screen: _showTagEditDialog/TextEditDialog/BreweryEditDialog に
  try/finally + controller.dispose() を追加(メモリリーク修正)
- sake_detail_screen: State フィールドを build() より前に移動
- 生例外の SnackBar 露出を人間可読メッセージに正規化(6ファイル・10箇所)
- camera_analysis_mixin: Colors.orange を appColors.warning に置換、
  ガミフィケーション色を brandAccent/success/textTertiary に統一
- sake_detail_screen: ハードコード hex 色グラデーションをトークン化
- scan_screen / pdf_preview_screen / add_set_item_dialog: 絵文字 debugPrint を除去
- sake_basic_info_section: unnecessary_non_null_assertion (warning) を解消
- license_service: revoked 永続キャッシュの意図をコメントで明確化
- dart analyze: warning 0 / error 0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 23:48:32 +09:00
Ponshu Developer 5d8689b7ee merge: claude/sync-cursor-history-cSlsP — SakeItem setter廃止 + セマンティックカラー置換
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 13:09:56 +09:00
Claude a62bcd1d11
refactor: ハードコード色をAppColorsセマンティックカラーに置換
意図的な箇所(カメラUI・AppBar白テキスト・ハート色・Proバッジ等)はKEEP。
以下のUIロジックに影響しない7ファイルのみ変更:

- Colors.grey.shade400/[400] → appColors.iconSubtle (アイコン、無効状態)
- Colors.grey.shade300 → appColors.divider (プレースホルダー背景)
- Colors.grey → appColors.textSecondary / iconSubtle / divider (文脈別)
- Colors.grey[200] → appColors.surfaceSubtle (プログレスバー背景)
- Colors.orange → appColors.warning (警告スナックバー)
- Colors.green / Colors.red → appColors.success / error (完了・失敗スナックバー)

_createBackup()にappColorsをasync前にキャプチャするFlutterベストプラクティスを適用。
コメント化されていたデッドコメントも同時削除。

https://claude.ai/code/session_01DWQpnqrQWwxVKKWSL9kDPp
2026-04-16 23:50:39 +00:00
Claude 8ebd233305
refactor: SakeItemのdisplayData setter危険性を排除し、テストを追加
- SakeItem.applyUpdates()を追加(displayData/hiddenSpecsを1回のsave()でアトミックに更新)
- displayData/hiddenSpecs setterに@Deprecatedを付与(save()忘れによるデータ消失防止)
- sakenowa_auto_matching_service.dartをapplyUpdates()に移行(setterの直接使用を撲滅)
- SakeAnalysisResult.fromJson()のユニットテストを新規追加(tasteStatsクランプ・欠損補完等)
- SakeItem.ensureMigrated()のユニットテストを追加

https://claude.ai/code/session_01DWQpnqrQWwxVKKWSL9kDPp
2026-04-16 23:40:48 +00:00
Ponshu Developer 05c27d9cdf chore: update download page to v1.0.42
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 21:12:18 +09:00
Ponshu Developer cc5175ebae refactor: ExposureSliderPainter を別ファイルに切り出し
camera_screen.dart の末尾にあった _ExposureSliderPainter (60行) を
lib/screens/camera_exposure_painter.dart に分離。
動作変更なし。718行 → 659行。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 21:08:30 +09:00
Ponshu Developer b7f5edf9a9 chore: update download page to v1.0.41
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 18:01:03 +09:00
Ponshu Developer ac2a54d07a feat: AI使用回数トラッキング + クォータ上限時ドラフト保存
- ApiUsageService: SharedPreferences で Gemini 日次使用回数を追跡
  - UTC 08:00(=日本時間 17:00)でリセット
  - 上限 20回/日(プロジェクトあたりの無料枠)
- DraftReason enum: offline / quotaLimit / congestion を区別
- camera_analysis_mixin: 解析前にクォータを事前チェック
  - 上限到達時は Draft 保存してカメラを閉じる(写真は失われない)
  - 429 エラー時も同様に Draft 保存(従来はエラー表示のみで写真消失)
  - API 呼び出し成功時(キャッシュ除く)にカウントアップ
- pending_analysis_screen: ドラフト理由を各アイテムに表示
  - クォータ: リセット時刻つきの警告(オレンジ)
  - 混雑 / オフライン: 理由別メッセージ
- ActivityStats: AI 使用状況 bento カードを追加
  - 今日のAI解析 X / 20回 + プログレスバー
  - 残り5回以下でオレンジ、上限到達で赤

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 17:58:00 +09:00
Ponshu Developer d39db78c80 chore: update download page to v1.0.40
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 14:04:01 +09:00
Ponshu Developer 4e6ff6d6e9 chore: bump version to 1.0.40+47
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 14:01:27 +09:00
Ponshu Developer 68723a884e feat: 記録日時を一覧カードと詳細画面に表示
- sake_list_item: カード末尾に相対日付を追加
  今日 / 昨日 / N日前 / M/D / YYYY/M/D
- sake_basic_info_section: 蔵元行直下にカレンダーアイコン + 「YYYY年M月D日に記録」を追加
  セット商品は非表示
- metadata.createdAt(非null保証)を使用、追加フィールド不要

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 14:01:17 +09:00
Ponshu Developer fad896e817 chore: update download page to v1.0.39 (actual APK sizes)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 13:40:23 +09:00
Ponshu Developer 26183e458e chore: update download page to v1.0.39
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 13:27:01 +09:00
Ponshu Developer cad2855b6e chore: bump version to 1.0.39+46
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 13:24:58 +09:00
51 changed files with 1831 additions and 571 deletions

View File

@ -28,5 +28,5 @@ GITEA_REPO=ponshu_room_lite
# VERCEL_PROJECT_ID=prj_xxxxxxxxxx
# APKビルド設定
MAITA_API_KEY=AIzaSyDjPZGOHy-xAstpLks081SIbUdTyb_iJpU
EIJI_API_KEY=AIzaSyBEwmTa9_2aiRrwr1mXE7Qriw8mIg1xr0U
MAITA_API_KEY=AIzaSy_YOUR_GEMINI_KEY_HERE
EIJI_API_KEY=AIzaSy_YOUR_GEMINI_KEY_HERE

View File

@ -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

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/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(),
),
);
}

View File

@ -157,12 +157,20 @@ class SakeItem extends HiveObject {
);
}
// Allow setting for UI updates ( await sakeItem.save() )
/// displayData/hiddenSpecs Hiveへ保存する
/// setterを直接使わずこのメソッドを使うこと
Future<void> applyUpdates({
DisplayData? displayData,
HiddenSpecs? hiddenSpecs,
}) async {
if (displayData != null) _displayData = displayData;
if (hiddenSpecs != null) _hiddenSpecs = hiddenSpecs;
await save();
}
@Deprecated('Use applyUpdates() instead to ensure save() is always called.')
set displayData(DisplayData val) {
_displayData = val;
// save() setter await
// unawaited save()
// sakenowa_auto_matching_service.dart await save()
}
HiddenSpecs get hiddenSpecs {
@ -176,7 +184,7 @@ class SakeItem extends HiveObject {
);
}
// Allow setting for auto-matching
@Deprecated('Use applyUpdates() instead to ensure save() is always called.')
set hiddenSpecs(HiddenSpecs val) {
_hiddenSpecs = val;
}

View File

@ -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(

View File

@ -0,0 +1,13 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
class QuotaLockoutNotifier extends Notifier<DateTime?> {
@override
DateTime? build() => null;
void set(DateTime? value) => state = value;
}
/// Gemini API 429
///
///
/// null = DateTime =
final quotaLockoutProvider = NotifierProvider<QuotaLockoutNotifier, DateTime?>(QuotaLockoutNotifier.new);

View File

@ -6,8 +6,10 @@ import 'package:lucide_icons/lucide_icons.dart';
import '../models/sake_item.dart';
import '../providers/gemini_provider.dart';
import '../providers/quota_lockout_provider.dart';
import '../providers/sakenowa_providers.dart';
import '../providers/theme_provider.dart';
import '../services/api_usage_service.dart';
import '../services/draft_service.dart';
import '../services/gamification_service.dart';
import '../services/gemini_exceptions.dart';
@ -23,7 +25,6 @@ import '../widgets/analyzing_dialog.dart';
/// - _performSakenowaMatching() :
mixin CameraAnalysisMixin<T extends ConsumerStatefulWidget> on ConsumerState<T> {
final List<String> capturedImages = [];
DateTime? quotaLockoutTime;
Future<void> analyzeImages() async {
if (capturedImages.isEmpty) return;
@ -31,6 +32,7 @@ mixin CameraAnalysisMixin<T extends ConsumerStatefulWidget> on ConsumerState<T>
// async gap context
final messenger = ScaffoldMessenger.of(context);
final navigator = Navigator.of(context);
final appColors = Theme.of(context).extension<AppColors>()!;
final isOnline = await NetworkService.isOnline();
if (!isOnline) {
@ -38,30 +40,30 @@ mixin CameraAnalysisMixin<T extends ConsumerStatefulWidget> on ConsumerState<T>
debugPrint('Offline detected: Saving as draft...');
try {
await DraftService.saveDraft(capturedImages);
await DraftService.saveDraft(capturedImages, reason: DraftReason.offline);
if (!mounted) return;
messenger.showSnackBar(
const SnackBar(
SnackBar(
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(LucideIcons.wifiOff, color: Colors.orange, size: 16),
SizedBox(width: 8),
Text('オフライン検知', style: TextStyle(fontWeight: FontWeight.bold)),
Icon(LucideIcons.wifiOff, color: Colors.white, size: 16),
const SizedBox(width: 8),
const Text('オフライン検知', style: TextStyle(fontWeight: FontWeight.bold)),
],
),
SizedBox(height: 4),
Text('写真を「解析待ち」として保存しました。'),
Text('オンライン復帰後、ホーム画面から解析できます。'),
const SizedBox(height: 4),
const Text('写真を「解析待ち」として保存しました。'),
const Text('オンライン復帰後、ホーム画面から解析できます。'),
],
),
duration: Duration(seconds: 5),
backgroundColor: Colors.orange,
duration: const Duration(seconds: 5),
backgroundColor: appColors.warning,
),
);
@ -71,26 +73,78 @@ mixin CameraAnalysisMixin<T extends ConsumerStatefulWidget> on ConsumerState<T>
debugPrint('Draft save error: $e');
if (!mounted) return;
messenger.showSnackBar(
SnackBar(content: Text('Draft保存エラー: $e')),
const SnackBar(content: Text('写真の一時保存に失敗しました。再度お試しください。')),
);
return;
}
}
// 20/UTC 08:00
final isQuotaExhausted = await ApiUsageService.isExhausted();
if (isQuotaExhausted) {
debugPrint('Quota exhausted: Saving as draft...');
try {
await DraftService.saveDraft(capturedImages, reason: DraftReason.quotaLimit);
if (!mounted) return;
final resetTime = ApiUsageService.getNextResetTime();
final resetStr = '${resetTime.hour}:${resetTime.minute.toString().padLeft(2, '0')}';
messenger.showSnackBar(
SnackBar(
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(LucideIcons.zap, color: Colors.white, size: 16),
const SizedBox(width: 8),
const Text('本日のAI解析上限20回に達しました',
style: TextStyle(fontWeight: FontWeight.bold)),
],
),
const SizedBox(height: 4),
const Text('写真を「解析待ち」として保存しました。'),
Text('$resetStr 以降にホーム画面から解析できます。'),
],
),
duration: const Duration(seconds: 6),
backgroundColor: appColors.warning,
),
);
navigator.pop(); //
} catch (e) {
debugPrint('Draft save error (quota): $e');
if (!mounted) return;
messenger.showSnackBar(
const SnackBar(content: Text('写真の一時保存に失敗しました。再度お試しください。')),
);
}
return;
}
// :
if (!mounted) return;
final stageNotifier = ValueNotifier<int>(1);
var stageNotifierDisposed = false;
// ignore: use_build_context_synchronously
// mounted BuildContext
showDialog(
context: context,
barrierDismissible: false,
builder: (context) => const AnalyzingDialog(),
builder: (context) => AnalyzingDialog(stageNotifier: stageNotifier),
);
try {
debugPrint('Starting Gemini Vision Direct Analysis for ${capturedImages.length} images');
debugPrint('Starting Gemini 2-stage analysis for ${capturedImages.length} images');
final geminiService = ref.read(geminiServiceProvider);
final result = await geminiService.analyzeSakeLabel(capturedImages);
final result = await geminiService.analyzeSakeLabel(
capturedImages,
onStep1Complete: () {
if (!stageNotifierDisposed) stageNotifier.value = 2;
},
);
// Create SakeItem (Schema v2.0)
final sakeItem = SakeItem(
@ -125,6 +179,12 @@ mixin CameraAnalysisMixin<T extends ConsumerStatefulWidget> on ConsumerState<T>
final box = Hive.box<SakeItem>('sake_items');
await box.add(sakeItem);
// API 使 API
if (!result.isFromCache) {
await ApiUsageService.increment();
ref.invalidate(apiUsageCountProvider);
}
//
//
_performSakenowaMatching(sakeItem).catchError((error) {
@ -133,8 +193,10 @@ mixin CameraAnalysisMixin<T extends ConsumerStatefulWidget> on ConsumerState<T>
// Prepend new item to sort order so it appears at the top
final settingsBox = Hive.box('settings');
final List<String> currentOrder = (settingsBox.get('sake_sort_order') as List<dynamic>?)
?.cast<String>() ?? [];
final rawOrder = settingsBox.get('sake_sort_order');
final List<String> currentOrder = (rawOrder is List)
? rawOrder.whereType<String>().toList()
: [];
currentOrder.insert(0, sakeItem.id);
await settingsBox.put('sake_sort_order', currentOrder);
@ -174,30 +236,27 @@ mixin CameraAnalysisMixin<T extends ConsumerStatefulWidget> on ConsumerState<T>
navigator.pop(); // Close Camera Screen (Return to Home)
// Success Message
final isDark = Theme.of(context).brightness == Brightness.dark;
final List<Widget> messageWidgets = [
Text('${sakeItem.displayData.displayName} を登録しました!'),
];
if (result.isFromCache) {
messageWidgets.add(const SizedBox(height: 4));
messageWidgets.add(const Text(
messageWidgets.add(Text(
'※ 解析済みの結果を使用(経験値なし)',
style: TextStyle(fontSize: 12, color: Colors.grey),
style: TextStyle(fontSize: 12, color: appColors.textTertiary),
));
} else {
messageWidgets.add(const SizedBox(height: 4));
messageWidgets.add(Row(
children: [
Icon(LucideIcons.sparkles,
color: isDark ? Colors.yellow.shade300 : Colors.yellow,
size: 16),
Icon(LucideIcons.sparkles, color: appColors.brandAccent, size: 16),
const SizedBox(width: 8),
Text(
'経験値 +$expGained GET!${isLevelUp ? " (Level UP!)" : ""}',
style: TextStyle(
fontWeight: FontWeight.bold,
color: isDark ? Colors.yellow.shade200 : Colors.yellowAccent,
color: appColors.brandAccent,
),
),
],
@ -216,7 +275,7 @@ mixin CameraAnalysisMixin<T extends ConsumerStatefulWidget> on ConsumerState<T>
'バッジ獲得: ${badge.name}',
style: TextStyle(
fontWeight: FontWeight.bold,
color: isDark ? Colors.green.shade300 : Colors.greenAccent,
color: appColors.success,
),
),
],
@ -243,29 +302,29 @@ mixin CameraAnalysisMixin<T extends ConsumerStatefulWidget> on ConsumerState<T>
// AIサーバー混雑503
if (e is GeminiCongestionException) {
try {
await DraftService.saveDraft(capturedImages);
await DraftService.saveDraft(capturedImages, reason: DraftReason.congestion);
if (!mounted) return;
navigator.pop(); // Close camera screen
messenger.showSnackBar(
const SnackBar(
SnackBar(
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
const Row(
children: [
Icon(LucideIcons.cloudOff, color: Colors.white, size: 16),
SizedBox(width: 8),
Text('AIサーバー混雑', style: TextStyle(fontWeight: FontWeight.bold)),
],
),
SizedBox(height: 4),
Text('写真を「解析待ち」として保存しました。'),
Text('時間をおいてホーム画面から解析できます。'),
const SizedBox(height: 4),
const Text('写真を「解析待ち」として保存しました。'),
const Text('時間をおいてホーム画面から解析できます。'),
],
),
duration: Duration(seconds: 5),
backgroundColor: Colors.orange,
duration: const Duration(seconds: 5),
backgroundColor: appColors.warning,
),
);
} catch (draftError) {
@ -278,26 +337,82 @@ mixin CameraAnalysisMixin<T extends ConsumerStatefulWidget> on ConsumerState<T>
return;
}
// Quota 429
// Quota 429
final errStr = e.toString();
if (errStr.contains('Quota') || errStr.contains('429')) {
setState(() {
quotaLockoutTime = DateTime.now().add(const Duration(minutes: 1));
});
}
final appColors = Theme.of(context).extension<AppColors>()!;
try {
await DraftService.saveDraft(capturedImages, reason: DraftReason.quotaLimit);
ref.read(quotaLockoutProvider.notifier).set(DateTime.now().add(const Duration(minutes: 1)));
if (!mounted) return;
navigator.pop(); //
final resetTime = ApiUsageService.getNextResetTime();
final resetStr = '${resetTime.hour}:${resetTime.minute.toString().padLeft(2, '0')}';
messenger.showSnackBar(
SnackBar(
content: Text('解析エラー: $e'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Row(
children: [
Icon(LucideIcons.zap, color: Colors.white, size: 16),
SizedBox(width: 8),
Text('本日のAI解析上限20回に達しました',
style: TextStyle(fontWeight: FontWeight.bold)),
],
),
const SizedBox(height: 4),
const Text('写真を「解析待ち」として保存しました。'),
Text('$resetStr 以降にホーム画面から解析できます。'),
],
),
duration: const Duration(seconds: 6),
backgroundColor: appColors.warning,
),
);
} catch (draftError) {
debugPrint('Draft save failed after 429: $draftError');
if (!mounted) return;
messenger.showSnackBar(
const SnackBar(content: Text('解析もドラフト保存も失敗しました。再試行してください。')),
);
}
return;
}
debugPrint('Analysis error: $e');
final errDetail = _extractErrorCode(e.toString());
messenger.showSnackBar(
SnackBar(
content: Text('解析に失敗しました。時間をおいて再試行してください。$errDetail'),
duration: const Duration(seconds: 5),
backgroundColor: appColors.error,
),
);
}
} finally {
stageNotifierDisposed = true;
stageNotifier.dispose();
}
}
/// HTTP
///
String _extractErrorCode(String err) {
final patterns = {
RegExp(r'\b(4\d{2}|5\d{2})\b'): (Match m) => ' [${m.group(0)}]',
RegExp(r'API_KEY_INVALID|PERMISSION_DENIED'): (_) => ' [key?]',
RegExp(r'RESOURCE_EXHAUSTED'): (_) => ' [quota]',
RegExp(r'NOT_FOUND'): (_) => ' [model?]',
RegExp(r'timeout', caseSensitive: false): (_) => ' [timeout]',
};
for (final entry in patterns.entries) {
final m = entry.key.firstMatch(err);
if (m != null) return entry.value(m);
}
return '';
}
///
///
///

View File

@ -0,0 +1,70 @@
import 'package:flutter/material.dart';
/// CustomPainter
///
/// -
/// - 0 EV
/// -
class ExposureSliderPainter extends CustomPainter {
final double currentValue;
final double minValue;
final double maxValue;
const ExposureSliderPainter({
required this.currentValue,
required this.minValue,
required this.maxValue,
});
@override
void paint(Canvas canvas, Size size) {
final trackPaint = Paint()
..color = Colors.white.withValues(alpha: 0.3)
..strokeWidth = 4
..strokeCap = StrokeCap.round;
final centerLinePaint = Paint()
..color = Colors.white54
..strokeWidth = 2;
final knobPaint = Paint()
..color = Colors.white
..style = PaintingStyle.fill;
final knobShadowPaint = Paint()
..color = Colors.black26
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 4);
//
final trackX = size.width / 2;
canvas.drawLine(
Offset(trackX, 10),
Offset(trackX, size.height - 10),
trackPaint,
);
// 0 EV
canvas.drawLine(
Offset(trackX - 6, size.height / 2),
Offset(trackX + 6, size.height / 2),
centerLinePaint,
);
//
final range = maxValue - minValue;
if (range > 0) {
// minValue() 0.0maxValue() 1.0 Y座標に変換
final normalized = (currentValue - minValue) / range;
final knobY = (size.height - 20) * (1.0 - normalized) + 10;
canvas.drawCircle(Offset(trackX, knobY), 7, knobShadowPaint);
canvas.drawCircle(Offset(trackX, knobY), 6, knobPaint);
}
}
@override
bool shouldRepaint(ExposureSliderPainter oldDelegate) {
return oldDelegate.currentValue != currentValue ||
oldDelegate.minValue != minValue ||
oldDelegate.maxValue != maxValue;
}
}

View File

@ -10,9 +10,11 @@ import 'package:gal/gal.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:image_picker/image_picker.dart'; // Gallery & Camera
import '../providers/quota_lockout_provider.dart';
import '../services/image_compression_service.dart';
import '../theme/app_colors.dart';
import 'camera_analysis_mixin.dart';
import 'camera_exposure_painter.dart';
enum CameraMode {
@ -164,10 +166,11 @@ class _CameraScreenState extends ConsumerState<CameraScreen> with SingleTickerPr
Future<void> _takePicture() async {
// Check Quota Lockout
if (quotaLockoutTime != null) {
final remaining = quotaLockoutTime!.difference(DateTime.now());
final quotaLockout = ref.read(quotaLockoutProvider);
if (quotaLockout != null) {
final remaining = quotaLockout.difference(DateTime.now());
if (remaining.isNegative) {
setState(() => quotaLockoutTime = null); // Reset
ref.read(quotaLockoutProvider.notifier).set(null);
} else {
final appColors = Theme.of(context).extension<AppColors>()!;
ScaffoldMessenger.of(context).showSnackBar(
@ -222,7 +225,7 @@ class _CameraScreenState extends ConsumerState<CameraScreen> with SingleTickerPr
final appColors = Theme.of(context).extension<AppColors>()!;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('ギャラリー保存に失敗しました: $e'),
content: const Text('ギャラリーへの保存に失敗しました'),
duration: const Duration(seconds: 4),
backgroundColor: appColors.warning,
),
@ -300,6 +303,7 @@ class _CameraScreenState extends ConsumerState<CameraScreen> with SingleTickerPr
// Batch handle - Notification only
if (mounted) {
final appColors = Theme.of(context).extension<AppColors>()!;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('${images.length}枚の画像を読み込みました。\n右下のボタンから解析を開始してください。'),
@ -307,7 +311,7 @@ class _CameraScreenState extends ConsumerState<CameraScreen> with SingleTickerPr
action: SnackBarAction(
label: '解析する',
onPressed: analyzeImages,
textColor: Colors.yellow,
textColor: appColors.brandAccent,
),
),
);
@ -366,6 +370,8 @@ class _CameraScreenState extends ConsumerState<CameraScreen> with SingleTickerPr
@override
Widget build(BuildContext context) {
final quotaLockout = ref.watch(quotaLockoutProvider);
final appColors = Theme.of(context).extension<AppColors>()!;
if (_cameraError != null) {
return Scaffold(
backgroundColor: Colors.black,
@ -475,8 +481,8 @@ class _CameraScreenState extends ConsumerState<CameraScreen> with SingleTickerPr
height: 180,
width: 48, // Wider for easier tapping
child: CustomPaint(
key: ValueKey(_currentExposureOffset), // Force repaint on value change
painter: _ExposureSliderPainter(
key: ValueKey(_currentExposureOffset),
painter: ExposureSliderPainter(
currentValue: _currentExposureOffset,
minValue: _minExposure,
maxValue: _maxExposure,
@ -563,22 +569,22 @@ class _CameraScreenState extends ConsumerState<CameraScreen> with SingleTickerPr
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: quotaLockoutTime != null ? Colors.red : Colors.white,
color: quotaLockout != null ? appColors.error : Colors.white,
width: 4
),
color: _isTakingPicture
? Colors.white.withValues(alpha: 0.5)
: (quotaLockoutTime != null ? Colors.red.withValues(alpha: 0.2) : Colors.transparent),
: (quotaLockout != null ? appColors.error.withValues(alpha: 0.2) : Colors.transparent),
),
child: Center(
child: quotaLockoutTime != null
child: quotaLockout != null
? const Icon(LucideIcons.timer, color: Colors.white, size: 30)
: Container(
height: 60,
width: 60,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: quotaLockoutTime != null ? Colors.grey : Colors.white,
color: quotaLockout != null ? appColors.textTertiary : Colors.white,
),
),
),
@ -590,7 +596,7 @@ class _CameraScreenState extends ConsumerState<CameraScreen> with SingleTickerPr
IconButton(
icon: Badge(
label: Text('${capturedImages.length}'),
child: const Icon(LucideIcons.playCircle, color: Colors.greenAccent, size: 40),
child: Icon(LucideIcons.playCircle, color: appColors.success, size: 40),
),
onPressed: analyzeImages,
tooltip: '解析を開始',
@ -656,63 +662,3 @@ class _CameraScreenState extends ConsumerState<CameraScreen> with SingleTickerPr
}
// Custom Painter for Exposure Slider
class _ExposureSliderPainter extends CustomPainter {
final double currentValue;
final double minValue;
final double maxValue;
_ExposureSliderPainter({
required this.currentValue,
required this.minValue,
required this.maxValue,
});
@override
void paint(Canvas canvas, Size size) {
final trackPaint = Paint()
..color = Colors.white.withValues(alpha: 0.3)
..strokeWidth = 4
..strokeCap = StrokeCap.round;
final centerLinePaint = Paint()
..color = Colors.white54
..strokeWidth = 2;
final knobPaint = Paint()
..color = Colors.white
..style = PaintingStyle.fill; final knobShadowPaint = Paint()
..color = Colors.black26
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 4); // Draw vertical track (centered)
final trackX = size.width / 2;
canvas.drawLine(
Offset(trackX, 10),
Offset(trackX, size.height - 10),
trackPaint,
); // Draw center marker
canvas.drawLine(
Offset(trackX - 6, size.height / 2),
Offset(trackX + 6, size.height / 2),
centerLinePaint,
); // Calculate knob position
final range = maxValue - minValue;
if (range > 0) {
// Normalize currentValue to 0.0-1.0 range
// minValue (e.g., -4.0) -> 0.0 (bottom)
// 0.0 (center) -> 0.5 (middle)
// maxValue (e.g., +4.0) -> 1.0 (top)
final normalized = (currentValue - minValue) / range;
// Map to Y coordinate: 0.0 (normalized) -> bottom, 1.0 (normalized) -> top
final knobY = (size.height - 20) * (1.0 - normalized) + 10;
// Draw knob shadow
canvas.drawCircle(Offset(trackX, knobY), 7, knobShadowPaint);
// Draw knob
canvas.drawCircle(Offset(trackX, knobY), 6, knobPaint);
}
} @override
bool shouldRepaint(_ExposureSliderPainter oldDelegate) {
return oldDelegate.currentValue != currentValue ||
oldDelegate.minValue != minValue ||
oldDelegate.maxValue != maxValue;
}
}

View File

@ -58,8 +58,9 @@ class _SommelierScreenState extends ConsumerState<SommelierScreen> {
);
} catch (e) {
if (mounted) {
debugPrint('Share error: $e');
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('シェアに失敗しました: $e')),
const SnackBar(content: Text('シェアに失敗しました。再度お試しください。')),
);
}
} finally {
@ -541,7 +542,7 @@ class _SommelierScreenState extends ConsumerState<SommelierScreen> {
debugPrint('Diagnosis Error: $e');
if (!mounted) return;
navigator.pop();
messenger.showSnackBar(SnackBar(content: Text('エラー: $e')));
messenger.showSnackBar(const SnackBar(content: Text('診断に失敗しました。時間をおいて再試行してください。')));
}
}

View File

@ -184,11 +184,11 @@ class HomeScreen extends ConsumerWidget {
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(LucideIcons.clipboardCheck, size: 60, color: Colors.grey[400]),
Icon(LucideIcons.clipboardCheck, size: 60, color: appColors.iconSubtle),
const SizedBox(height: 16),
Text(t['noMenuItems'], style: const TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
Text(t['goBackToList'], textAlign: TextAlign.center, style: const TextStyle(color: Colors.grey)),
Text(t['goBackToList'], textAlign: TextAlign.center, style: TextStyle(color: appColors.textSecondary)),
],
),
);

View File

@ -65,7 +65,7 @@ class _MainScreenState extends ConsumerState<MainScreen> {
clipBehavior: Clip.none,
alignment: Alignment.center,
children: [
Icon(featureIcon, size: 48, color: Colors.grey.shade400),
Icon(featureIcon, size: 48, color: appColors.iconSubtle),
Positioned(
right: -8,
top: -8,

View File

@ -27,27 +27,26 @@ class _MenuPricingScreenState extends ConsumerState<MenuPricingScreen> {
@override
void initState() {
super.initState();
// One-time hint for Exit button
WidgetsBinding.instance.addPostFrameCallback((_) {
final prefs = SharedPreferences.getInstance();
final shown = prefs.then((p) => p.getBool('business_mode_help_shown') ?? false);
WidgetsBinding.instance.addPostFrameCallback((_) => _showExitHintIfNeeded());
}
shown.then((hasShown) {
Future<void> _showExitHintIfNeeded() async {
try {
final prefs = await SharedPreferences.getInstance();
final hasShown = prefs.getBool('business_mode_help_shown') ?? false;
if (!hasShown && mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Text('右上の×でいつでも終了できます'),
duration: const Duration(seconds: 3),
action: SnackBarAction(
label: 'OK',
onPressed: () {},
),
action: SnackBarAction(label: 'OK', onPressed: () {}),
),
);
prefs.then((p) => p.setBool('business_mode_help_shown', true));
await prefs.setBool('business_mode_help_shown', true);
}
} catch (e) {
debugPrint('Failed to load/save business_mode_help_shown: $e');
}
});
});
}
@override
@ -123,7 +122,7 @@ class _MenuPricingScreenState extends ConsumerState<MenuPricingScreen> {
preferredSize: const Size.fromHeight(2),
child: LinearProgressIndicator(
value: 2 / 3, // Step 2 of 3 = 66%
backgroundColor: Colors.grey[200],
backgroundColor: Theme.of(context).extension<AppColors>()!.surfaceSubtle,
valueColor: AlwaysStoppedAnimation<Color>(Theme.of(context).primaryColor),
minHeight: 2,
),
@ -320,9 +319,9 @@ class _MenuPricingScreenState extends ConsumerState<MenuPricingScreen> {
// Drag Handle
ReorderableDragStartListener(
index: index,
child: const Padding(
padding: EdgeInsets.only(right: 12, top: 4, bottom: 4),
child: Icon(Icons.drag_indicator, color: Colors.grey),
child: Padding(
padding: const EdgeInsets.only(right: 12, top: 4, bottom: 4),
child: Icon(Icons.drag_indicator, color: appColors.iconSubtle),
),
),
Expanded(

View File

@ -95,7 +95,7 @@ class PdfPreviewScreen extends ConsumerWidget {
],
),
loadingWidget: const Center(child: CircularProgressIndicator()),
onError: (context, error) => Center(child: Text('エラーが発生しました: $error')),
onError: (context, error) => const Center(child: Text('PDFの表示に失敗しました')),
),
bottomNavigationBar: SafeArea(
child: Column(
@ -246,9 +246,10 @@ class PdfPreviewScreen extends ConsumerWidget {
filename: 'oshinagaki_${DateTime.now().toString().split(' ')[0]}.pdf',
);
} catch (e) {
debugPrint('PDF share error: $e');
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('共有エラー: $e')),
const SnackBar(content: Text('PDFの共有に失敗しました。再度お試しください。')),
);
}
}
@ -284,7 +285,7 @@ class PdfPreviewScreen extends ConsumerWidget {
..name = fileName
..mimeType = 'application/pdf';
debugPrint('[PDF_DRIVE] 📤 アップロード開始: $fileName (${bytes.length} bytes)');
debugPrint('[PDF_DRIVE] Upload start: $fileName (${bytes.length} bytes)');
final uploadedFile = await driveApi.files.create(
driveFile,
uploadMedia: drive.Media(
@ -294,11 +295,11 @@ class PdfPreviewScreen extends ConsumerWidget {
);
if (uploadedFile.id == null) {
debugPrint('[PDF_DRIVE] ❌ アップロード失敗: ID取得不可');
debugPrint('[PDF_DRIVE] Upload failed: no file ID returned');
throw Exception('アップロードに失敗しましたIDなし');
}
debugPrint('[PDF_DRIVE] ✅ アップロード完了: ID=${uploadedFile.id}');
debugPrint('[PDF_DRIVE] Upload complete: ID=${uploadedFile.id}');
// 5. Success notification
if (context.mounted) {
@ -314,11 +315,12 @@ class PdfPreviewScreen extends ConsumerWidget {
);
}
} catch (e) {
debugPrint('Drive upload error: $e');
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Driveアップロードエラー: $e'),
duration: const Duration(seconds: 4),
const SnackBar(
content: Text('Google Driveへの保存に失敗しました。再度お試しください。'),
duration: Duration(seconds: 4),
),
);
}
@ -336,9 +338,10 @@ class PdfPreviewScreen extends ConsumerWidget {
format: _getPageFormat(pdfSize, ref.read(pdfIsPortraitProvider)),
);
} catch (e) {
debugPrint('Print error: $e');
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('印刷エラー: $e')),
const SnackBar(content: Text('印刷に失敗しました。再度お試しください。')),
);
}
}

View File

@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lucide_icons/lucide_icons.dart';
import '../models/sake_item.dart';
import '../providers/gemini_provider.dart';
import '../services/api_usage_service.dart';
import '../services/draft_service.dart';
import '../services/network_service.dart';
import '../theme/app_colors.dart';
@ -54,6 +55,24 @@ class _PendingAnalysisScreenState extends ConsumerState<PendingAnalysisScreen> {
}
}
///
String _getDraftNote(SakeItem draft) {
final desc = draft.hiddenSpecs.description ?? '';
switch (desc) {
case 'quota':
final resetTime = ApiUsageService.getNextResetTime();
final resetStr = '${resetTime.hour}:${resetTime.minute.toString().padLeft(2, '0')}';
return 'AI上限20回/日)に達したため保留中。次のリセット $resetStr 以降に解析可';
case 'congestion':
return 'AIサーバー混雑のため保留中。「すべて解析」で再試行できます';
default:
return 'オフライン時に保存。オンライン接続後に解析できます';
}
}
bool _isQuotaDraft(SakeItem draft) =>
draft.hiddenSpecs.description == 'quota';
Future<void> _analyzeAllDrafts() async {
final appColors = Theme.of(context).extension<AppColors>()!;
@ -175,10 +194,11 @@ class _PendingAnalysisScreenState extends ConsumerState<PendingAnalysisScreen> {
}
} catch (e) {
if (!mounted) return;
debugPrint('Draft delete error: $e');
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('削除エラー: $e'),
duration: const Duration(seconds: 5),
const SnackBar(
content: Text('削除に失敗しました。再度お試しください。'),
duration: Duration(seconds: 5),
),
);
}
@ -338,25 +358,49 @@ class _PendingAnalysisScreenState extends ConsumerState<PendingAnalysisScreen> {
width: 60,
height: 60,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => Container(
width: 60,
height: 60,
decoration: BoxDecoration(
color: appColors.divider,
borderRadius: BorderRadius.circular(8),
),
child: Icon(LucideIcons.image, color: appColors.iconSubtle),
),
),
)
: Container(
width: 60,
height: 60,
decoration: BoxDecoration(
color: Colors.grey.shade300,
color: appColors.divider,
borderRadius: BorderRadius.circular(8),
),
child: const Icon(LucideIcons.image, color: Colors.grey),
child: Icon(LucideIcons.image, color: appColors.iconSubtle),
),
title: const Text(
'解析待ち',
style: TextStyle(fontWeight: FontWeight.bold),
),
subtitle: Text(
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'撮影日時: ${draft.metadata.createdAt.toString().substring(0, 16)}',
style: TextStyle(fontSize: 12, color: appColors.textSecondary),
),
const SizedBox(height: 2),
Text(
_getDraftNote(draft),
style: TextStyle(
fontSize: 11,
color: _isQuotaDraft(draft)
? Colors.orange
: appColors.textTertiary,
),
),
],
),
trailing: IconButton(
icon: Icon(LucideIcons.trash2, color: appColors.error),
onPressed: () => _deleteDraft(draft),
@ -390,7 +434,7 @@ class _PendingAnalysisScreenState extends ConsumerState<PendingAnalysisScreen> {
style: ElevatedButton.styleFrom(
backgroundColor: appColors.brandPrimary,
foregroundColor: Colors.white,
disabledBackgroundColor: Colors.grey.shade400,
disabledBackgroundColor: appColors.textTertiary,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),

View File

@ -8,6 +8,10 @@ import '../../../constants/app_constants.dart';
import '../../../providers/theme_provider.dart';
import '../../../services/mbti_compatibility_service.dart';
String _formatRecordedDate(DateTime date) {
return '${date.year}${date.month}${date.day}日に記録';
}
/// /AI確信度バッジMBTI相性バッジ
class SakeBasicInfoSection extends ConsumerWidget {
final SakeItem sake;
@ -97,7 +101,7 @@ class SakeBasicInfoSection extends ConsumerWidget {
decoration: BoxDecoration(
color: badgeColor!.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: badgeColor!.withValues(alpha: 0.4)),
border: Border.all(color: badgeColor.withValues(alpha: 0.4)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
@ -105,7 +109,7 @@ class SakeBasicInfoSection extends ConsumerWidget {
Icon(LucideIcons.brainCircuit, size: 12, color: badgeColor),
const SizedBox(width: 4),
Text(
'$mbtiType ${mbtiResult!.starDisplay}',
'$mbtiType ${mbtiResult.starDisplay}',
style: TextStyle(
color: badgeColor,
fontWeight: FontWeight.bold,
@ -161,6 +165,24 @@ class SakeBasicInfoSection extends ConsumerWidget {
),
),
),
const SizedBox(height: 8),
//
if (sake.itemType != ItemType.set)
Center(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(LucideIcons.calendarDays, size: 11, color: appColors.textTertiary),
const SizedBox(width: 4),
Text(
_formatRecordedDate(sake.metadata.createdAt),
style: TextStyle(fontSize: 11, color: appColors.textTertiary),
),
],
),
),
const SizedBox(height: 20),
// 調

View File

@ -70,6 +70,10 @@ class SakeDetailSliverAppBar extends StatelessWidget {
final imageWidget = Image.file(
File(sake.displayData.imagePaths[index]),
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) => Container(
color: Colors.grey[300],
child: const Icon(Icons.broken_image, size: 60, color: Colors.grey),
),
);
// Apply Hero only to the first image for smooth transition from Grid/List
@ -121,6 +125,10 @@ class SakeDetailSliverAppBar extends StatelessWidget {
? Image.file(
File(sake.displayData.imagePaths.first),
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) => Container(
color: appColors.surfaceSubtle,
child: Icon(LucideIcons.imageOff, size: 80, color: appColors.iconSubtle),
),
)
: Container(
color: appColors.surfaceSubtle,

View File

@ -112,6 +112,12 @@ class _SakePhotoEditModalState extends State<SakePhotoEditModal> {
width: 60,
height: 60,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) => Container(
width: 60,
height: 60,
color: Colors.grey[200],
child: const Icon(Icons.broken_image, size: 24, color: Colors.grey),
),
),
),
title: Text(
@ -234,9 +240,10 @@ class _SakePhotoEditModalState extends State<SakePhotoEditModal> {
await _saveNewPhoto(savedPath);
} catch (e) {
debugPrint('Photo pick error: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('エラー: $e')),
const SnackBar(content: Text('写真の追加に失敗しました。再度お試しください。')),
);
}
}

View File

@ -19,6 +19,7 @@ import '../widgets/sake_detail/sake_detail_specs.dart';
import 'sake_detail/widgets/sake_photo_edit_modal.dart';
import 'sake_detail/sections/sake_mbti_stamp_section.dart';
import '../providers/license_provider.dart';
import '../providers/quota_lockout_provider.dart';
import 'sake_detail/sections/sake_basic_info_section.dart';
import 'sake_detail/widgets/sake_detail_sliver_app_bar.dart';
import '../services/mbti_compatibility_service.dart';
@ -36,10 +37,9 @@ class SakeDetailScreen extends ConsumerStatefulWidget {
}
class _SakeDetailScreenState extends ConsumerState<SakeDetailScreen> {
// To trigger rebuilds if we don't switch to a stream
late SakeItem _sake;
int _currentImageIndex = 0;
// Memo logic moved to SakeDetailMemo
bool _isAnalyzing = false;
@override
@ -119,14 +119,11 @@ class _SakeDetailScreenState extends ConsumerState<SakeDetailScreen> {
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: Theme.of(context).brightness == Brightness.dark
? [
const Color(0xFF121212), // Scaffold Background
const Color(0xFF1E1E1E), // Slightly lighter surface
]
: [
colors: [
Theme.of(context).scaffoldBackgroundColor,
Theme.of(context).primaryColor.withValues(alpha: 0.05),
Theme.of(context).brightness == Brightness.dark
? appColors.brandSurface
: Theme.of(context).primaryColor.withValues(alpha: 0.05),
],
),
),
@ -281,10 +278,6 @@ class _SakeDetailScreenState extends ConsumerState<SakeDetailScreen> {
);
}
bool _isAnalyzing = false;
DateTime? _quotaLockoutTime;
Future<void> _toggleFavorite() async {
HapticFeedback.mediumImpact();
final box = Hive.box<SakeItem>('sake_items');
@ -309,13 +302,18 @@ class _SakeDetailScreenState extends ConsumerState<SakeDetailScreen> {
// 1. Check Locks
if (_isAnalyzing) return;
// async gap context
final nav = Navigator.of(context);
final messenger = ScaffoldMessenger.of(context);
// 2. Check Quota Lockout
if (_quotaLockoutTime != null) {
final remaining = _quotaLockoutTime!.difference(DateTime.now());
final quotaLockout = ref.read(quotaLockoutProvider);
if (quotaLockout != null) {
final remaining = quotaLockout.difference(DateTime.now());
if (remaining.isNegative) {
setState(() => _quotaLockoutTime = null); // Reset if time passed
ref.read(quotaLockoutProvider.notifier).set(null);
} else {
ScaffoldMessenger.of(context).showSnackBar(
messenger.showSnackBar(
SnackBar(content: Text('AI利用制限中です。あと${remaining.inSeconds}秒お待ちください。')),
);
return;
@ -332,8 +330,6 @@ class _SakeDetailScreenState extends ConsumerState<SakeDetailScreen> {
}
}
if (!mounted) return;
final nav = Navigator.of(context);
final messenger = ScaffoldMessenger.of(context);
if (existingPaths.isEmpty) {
messenger.showSnackBar(
@ -346,15 +342,16 @@ class _SakeDetailScreenState extends ConsumerState<SakeDetailScreen> {
try {
// ignore: use_build_context_synchronously
// mounted 334 await
showDialog(
context: context,
barrierDismissible: false,
builder: (context) => const AnalyzingDialog(),
);
showDialog(context: context, barrierDismissible: false, builder: (context) => const AnalyzingDialog());
final geminiService = ref.read(geminiServiceProvider);
final result = await geminiService.analyzeSakeLabel(existingPaths, forceRefresh: true);
// : name/brand
// temperature=0.3 hallucination
final result = await geminiService.reanalyzeSakeLabel(
existingPaths,
previousName: _sake.displayData.displayName,
previousBrand: _sake.displayData.displayBrewery,
);
final newItem = _sake.copyWith(
name: result.name ?? _sake.displayData.displayName,
@ -396,13 +393,12 @@ class _SakeDetailScreenState extends ConsumerState<SakeDetailScreen> {
// Check for Quota Error to set Lockout
if (e.toString().contains('Quota') || e.toString().contains('429')) {
setState(() {
_quotaLockoutTime = DateTime.now().add(const Duration(minutes: 1));
});
ref.read(quotaLockoutProvider.notifier).set(DateTime.now().add(const Duration(minutes: 1)));
}
debugPrint('Reanalyze error: $e');
messenger.showSnackBar(
SnackBar(content: Text('エラー: $e')),
const SnackBar(content: Text('再解析に失敗しました。時間をおいて再試行してください。')),
);
}
} finally {
@ -413,11 +409,11 @@ class _SakeDetailScreenState extends ConsumerState<SakeDetailScreen> {
}
void _showTagEditDialog(BuildContext context) {
Future<void> _showTagEditDialog(BuildContext context) async {
final TextEditingController tagController = TextEditingController();
final allTags = _sake.hiddenSpecs.flavorTags.toSet();
showDialog(
try {
await showDialog(
context: context,
builder: (ctx) => StatefulBuilder(
builder: (context, setModalState) {
@ -498,6 +494,9 @@ class _SakeDetailScreenState extends ConsumerState<SakeDetailScreen> {
}
),
);
} finally {
tagController.dispose();
}
}
Future<void> _updateTags(List<String> newTags) async {
@ -581,6 +580,7 @@ class _SakeDetailScreenState extends ConsumerState<SakeDetailScreen> {
required Future<void> Function(String) onSave,
}) async {
final controller = TextEditingController(text: initialValue);
try {
await showDialog(
context: context,
builder: (context) => AlertDialog(
@ -608,6 +608,9 @@ class _SakeDetailScreenState extends ConsumerState<SakeDetailScreen> {
],
),
);
} finally {
controller.dispose();
}
}
/// MBTI相性詳細ダイアログを表示
@ -732,6 +735,7 @@ class _SakeDetailScreenState extends ConsumerState<SakeDetailScreen> {
Future<void> _showBreweryEditDialog(BuildContext context) async {
final breweryController = TextEditingController(text: _sake.displayData.displayBrewery);
final prefectureController = TextEditingController(text: _sake.displayData.displayPrefecture);
try {
await showDialog(
context: context,
builder: (context) => AlertDialog(
@ -778,6 +782,10 @@ class _SakeDetailScreenState extends ConsumerState<SakeDetailScreen> {
],
),
);
} finally {
breweryController.dispose();
prefectureController.dispose();
}
}
///

View File

@ -77,10 +77,10 @@ class _ScanARScreenState extends ConsumerState<ScanARScreen>
_isInitializing = false;
});
}
debugPrint('✅ Scanner: Controller created successfully');
debugPrint('[Scanner] Controller created successfully');
} catch (e) {
debugPrint('❌ Scanner: Error during initialization: $e');
debugPrint('[Scanner] Error during initialization: $e');
if (mounted) {
setState(() {
_isInitializing = false;
@ -395,7 +395,7 @@ class _DigitalSakeCardDialog extends StatelessWidget {
const SizedBox(height: 8),
Text(
'$brewery / $prefecture',
style: const TextStyle(fontSize: 14, color: Colors.grey),
style: TextStyle(fontSize: 14, color: appColors.textTertiary),
),
const SizedBox(height: 20),

View File

@ -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,
),
),
],

View File

@ -0,0 +1,75 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// Gemini API 使
///
/// - : 1 20/
/// - : UTC 08:00= 17:00 JST / 16:00 JST
/// Gemini API Pacific Time UTC+9
/// (PST=UTC-8) 17:00 JST(PDT=UTC-7) 16:00 JST
class ApiUsageService {
static const int dailyLimit = 20;
static const _keyCount = 'gemini_usage_count';
static const _keyWindowStart = 'gemini_window_start';
/// UTC 08:00
static DateTime getCurrentWindowStart() {
final now = DateTime.now().toUtc();
final todayReset = DateTime.utc(now.year, now.month, now.day, 8, 0, 0);
return now.isBefore(todayReset)
? todayReset.subtract(const Duration(days: 1))
: todayReset;
}
///
static DateTime getNextResetTime() {
return getCurrentWindowStart().add(const Duration(days: 1)).toLocal();
}
/// 使
static Future<int> getCount() async {
final prefs = await SharedPreferences.getInstance();
final windowStart = getCurrentWindowStart();
final storedStr = prefs.getString(_keyWindowStart);
if (storedStr != null) {
final storedWindow = DateTime.parse(storedStr);
if (storedWindow.isBefore(windowStart)) {
//
await prefs.setInt(_keyCount, 0);
await prefs.setString(_keyWindowStart, windowStart.toIso8601String());
return 0;
}
} else {
//
await prefs.setString(_keyWindowStart, windowStart.toIso8601String());
}
return prefs.getInt(_keyCount) ?? 0;
}
/// 使 1
static Future<void> increment() async {
final prefs = await SharedPreferences.getInstance();
final current = await getCount();
await prefs.setInt(_keyCount, current + 1);
}
/// 0
static Future<int> getRemaining() async {
final count = await getCount();
return (dailyLimit - count).clamp(0, dailyLimit);
}
/// 使
static Future<bool> isExhausted() async {
return await getCount() >= dailyLimit;
}
}
/// ActivityStats / 使 Riverpod
/// increment() ref.invalidate(apiUsageCountProvider) UI
final apiUsageCountProvider = FutureProvider.autoDispose<int>((ref) async {
return ApiUsageService.getCount();
});

View File

@ -11,6 +11,15 @@ import 'package:path_provider/path_provider.dart';
import 'package:path/path.dart' as path;
import '../models/sake_item.dart';
///
/// UI /使
class PreRestoreBackupException implements Exception {
const PreRestoreBackupException();
@override
String toString() => 'PreRestoreBackupException: pre-restore safety backup failed';
}
/// Google Driveへのバックアップ
///
///
@ -325,8 +334,11 @@ class BackupService {
final driveApi = drive.DriveApi(authClient);
// 3. 退
await _createPreRestoreBackup();
// 3. 退
final preBackupOk = await _createPreRestoreBackup();
if (!preBackupOk) {
throw const PreRestoreBackupException();
}
// 4. Google Driveからダウンロード
final zipFile = await _downloadFromDrive(driveApi);
@ -342,26 +354,55 @@ class BackupService {
await zipFile.delete();
return success;
} on PreRestoreBackupException {
rethrow;
} catch (error) {
debugPrint('[RESTORE] Restore error: $error');
return false;
}
}
/// 退
Future<void> _createPreRestoreBackup() async {
/// 退 true false
Future<bool> _createPreRestoreBackup() async {
try {
final tempDir = await getTemporaryDirectory();
final backupPath = path.join(tempDir.path, 'pre_restore_backup.zip');
final zipFile = await _createBackupZip();
if (zipFile != null) {
if (zipFile == null) {
debugPrint('[RESTORE] Pre-restore backup: ZIP creation failed');
return false;
}
await zipFile.copy(backupPath);
await zipFile.delete();
debugPrint('[RESTORE] Pre-restore backup saved: $backupPath');
}
return true;
} catch (error) {
debugPrint('[RESTORE] Pre-restore backup error: $error');
return false;
}
}
///
Future<bool> restoreBackupSkippingPreBackup() async {
try {
final account = _googleSignIn.currentUser;
if (account == null) return false;
final authClient = await _googleSignIn.authenticatedClient();
if (authClient == null) return false;
final driveApi = drive.DriveApi(authClient);
final zipFile = await _downloadFromDrive(driveApi);
if (zipFile == null) return false;
final success = await _restoreFromZip(zipFile);
await zipFile.delete();
return success;
} catch (error) {
debugPrint('[RESTORE] Force restore error: $error');
return false;
}
}
@ -393,7 +434,14 @@ class BackupService {
// 3.
final sink = downloadFile.openWrite();
await media.stream.pipe(sink);
await media.stream.pipe(sink).timeout(
const Duration(minutes: 3),
onTimeout: () {
sink.close();
try { downloadFile.deleteSync(); } catch (_) {}
throw TimeoutException('Backup download timed out after 3 minutes');
},
);
debugPrint('[RESTORE] Download complete: $downloadPath');
return downloadFile;
@ -455,7 +503,10 @@ class BackupService {
final sakeBox = Hive.box<SakeItem>('sake_items');
await sakeBox.clear();
int restoredCount = 0;
int skippedCount = 0;
for (var itemData in sakeItemsJson) {
try {
final data = itemData as Map<String, dynamic>;
// JSONからSakeItemオブジェクトを再構築
@ -467,21 +518,21 @@ class BackupService {
prefecture: data['displayData']['prefecture'] as String,
catchCopy: data['displayData']['catchCopy'] as String?,
imagePaths: List<String>.from(data['displayData']['imagePaths'] as List),
rating: data['displayData']['rating'] as double?,
rating: (data['displayData']['rating'] as num?)?.toDouble(),
),
hiddenSpecs: HiddenSpecs(
description: data['hiddenSpecs']['description'] as String?,
tasteStats: Map<String, int>.from(data['hiddenSpecs']['tasteStats'] as Map),
flavorTags: List<String>.from(data['hiddenSpecs']['flavorTags'] as List),
sweetnessScore: data['hiddenSpecs']['sweetnessScore'] as double?,
bodyScore: data['hiddenSpecs']['bodyScore'] as double?,
sweetnessScore: (data['hiddenSpecs']['sweetnessScore'] as num?)?.toDouble(),
bodyScore: (data['hiddenSpecs']['bodyScore'] as num?)?.toDouble(),
),
userData: UserData(
isFavorite: data['userData']['isFavorite'] as bool,
isUserEdited: data['userData']['isUserEdited'] as bool,
price: data['userData']['price'] as int?,
costPrice: data['userData']['costPrice'] as int?,
markup: data['userData']['markup'] as double,
markup: (data['userData']['markup'] as num?)?.toDouble() ?? 3.0,
priceVariants: data['userData']['priceVariants'] != null
? Map<String, int>.from(data['userData']['priceVariants'] as Map)
: null,
@ -498,8 +549,13 @@ class BackupService {
// IDを保持するためにput()使add()
await sakeBox.put(item.id, item);
restoredCount++;
} catch (e) {
skippedCount++;
debugPrint('[RESTORE] Skipped malformed item: $e');
}
debugPrint('[RESTORE] SakeItems restored (${sakeItemsJson.length} items)');
}
debugPrint('[RESTORE] SakeItems restored ($restoredCount items, $skippedCount skipped)');
// UI更新のためにわずかに待機
await Future.delayed(const Duration(milliseconds: 500));
}

View File

@ -4,6 +4,13 @@ import 'package:uuid/uuid.dart';
import '../models/sake_item.dart';
import 'gemini_service.dart';
/// Draft
enum DraftReason {
offline, //
quotaLimit, // AI 使20/
congestion, // AI 503
}
/// Draft
///
///
@ -29,13 +36,23 @@ class DraftService {
/// ```dart
/// final draftKey = await DraftService.saveDraft([imagePath1, imagePath2]);
/// ```
static Future<String> saveDraft(List<String> photoPaths) async {
static Future<String> saveDraft(
List<String> photoPaths, {
DraftReason reason = DraftReason.offline,
}) async {
try {
final box = Hive.box<SakeItem>('sake_items');
// FIX: draftPhotoPathにimagePathsに保存
final firstPhotoPath = photoPaths.isNotEmpty ? photoPaths.first : '';
// reason description
final reasonKey = switch (reason) {
DraftReason.offline => 'offline',
DraftReason.quotaLimit => 'quota',
DraftReason.congestion => 'congestion',
};
// Draft用の仮データを作成
final draftItem = SakeItem(
id: _uuid.v4(),
@ -49,7 +66,7 @@ class DraftService {
rating: null,
),
hiddenSpecs: HiddenSpecs(
description: 'オフライン時に撮影された写真です。オンライン復帰後に自動解析されます。',
description: reasonKey,
tasteStats: {},
flavorTags: [],
),

View File

@ -13,30 +13,309 @@ class GeminiService {
// AI Proxy Server Configuration
static final String _proxyUrl = Secrets.aiProxyAnalyzeUrl;
// ? Proxy側で管理されているが
static DateTime? _lastApiCallTime;
static const Duration _minApiInterval = Duration(seconds: 2);
GeminiService();
///
Future<SakeAnalysisResult> analyzeSakeLabel(List<String> imagePaths, {bool forceRefresh = false}) async {
//
const prompt = '''
// ============================================================
// Public API
// ============================================================
/// 2: OCR
///
/// [onStep1Complete]: Stage 1
/// UI 2使
/// APIモードconsumer 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を返してください
## namebrandprefectureの読み取り
3
-
- "name": "東魁"
- 鹿 "name": "白鹿"鹿
- "name": "久保田" 寿
- prefecture: null
## namebrandprefectureの読み取りOCR厳守
3
##
使
- (2) "東魁"
- 鹿(2) "白鹿" 鹿
- (3) "久保田" 寿
- (2) "男山"
- (2) "白鶴"
- (3) "松竹梅"
N N
- prefecture: null
##
namebrand
-
-
##
使
- type: null
- description: type 100
- catchCopy: 20
@ -71,25 +350,15 @@ class GeminiService {
}
''';
return _callProxyApi(
imagePaths: imagePaths,
customPrompt: prompt, // Override server default
forceRefresh: forceRefresh,
);
}
// ============================================================
// APIコールiOSビルド用 / USE_PROXY=true
// ============================================================
/// : ProxyへのAPIコール
Future<SakeAnalysisResult> _callProxyApi({
required List<String> imagePaths,
String? customPrompt,
bool forceRefresh = false,
}) async {
// Check Mode: Direct vs Proxy
if (!Secrets.useProxy) {
debugPrint('Direct Cloud Mode: Connecting to Gemini API directly...');
return _callDirectApi(imagePaths, customPrompt, forceRefresh: forceRefresh);
}
// 1. forceRefresh=false
if (!forceRefresh && imagePaths.isNotEmpty) {
final imageHash = await AnalysisCacheService.computeCombinedHash(imagePaths);
@ -110,21 +379,20 @@ class GeminiService {
}
_lastApiCallTime = DateTime.now();
// 2. Base64変換
// 3. Base64変換
List<String> base64Images = [];
for (final path in imagePaths) {
// Read already-compressed images directly (compressed at capture time)
final bytes = await File(path).readAsBytes();
final base64String = base64Encode(bytes);
base64Images.add(base64String);
debugPrint('Encoded processed image: ${(bytes.length / 1024).toStringAsFixed(1)}KB');
}
// 3. ID取得
// 4. ID取得
final deviceId = await DeviceService.getDeviceId();
debugPrint('Device ID: $deviceId');
if (kDebugMode) debugPrint('Device ID: $deviceId');
// 4.
// 5.
final requestBody = jsonEncode({
"device_id": deviceId,
"images": base64Images,
@ -133,7 +401,7 @@ class GeminiService {
debugPrint('Calling Proxy: $_proxyUrl');
// 5. Bearer Token認証付き
// 6. Bearer Token認証付き
final headers = {
"Content-Type": "application/json",
if (Secrets.proxyAuthToken.isNotEmpty)
@ -143,18 +411,16 @@ class GeminiService {
Uri.parse(_proxyUrl),
headers: headers,
body: requestBody,
).timeout(const Duration(seconds: 60)); // : 60 ()
).timeout(const Duration(seconds: 60));
// 6.
// 7.
if (response.statusCode == 200) {
// : { "success": true, "data": {...}, "usage": {...} }
final jsonResponse = jsonDecode(utf8.decode(response.bodyBytes));
if (jsonResponse['success'] == true) {
final data = jsonResponse['data'];
if (data == null) throw Exception("サーバーからのデータが空です");
// 使
if (jsonResponse['usage'] != null) {
final usage = jsonResponse['usage'];
debugPrint('API Usage: ${usage['today']}/${usage['limit']}');
@ -162,9 +428,6 @@ class GeminiService {
final result = SakeAnalysisResult.fromJson(data);
// tasteStats SakeAnalysisResult.fromJson
// API不使用
if (imagePaths.isNotEmpty) {
final imageHash = await AnalysisCacheService.computeCombinedHash(imagePaths);
await AnalysisCacheService.saveCache(imageHash, result);
@ -177,11 +440,9 @@ class GeminiService {
return result;
} else {
// Proxy側での論理エラー ()
throw Exception(jsonResponse['error'] ?? '不明なエラーが発生しました');
}
} else {
// HTTPエラー
if (kDebugMode) {
debugPrint('Proxy Error: ${response.statusCode} ${response.body}');
}
@ -190,7 +451,6 @@ class GeminiService {
} catch (e) {
debugPrint('Proxy Call Failed: $e');
//
final errorMsg = e.toString().toLowerCase();
if (errorMsg.contains('limit') || errorMsg.contains('上限')) {
throw Exception('本日のAI解析リクエスト上限に達しました。\n明日またお試しください。');
@ -199,10 +459,16 @@ class GeminiService {
}
}
/// Direct Cloud API Implementation (No Proxy)
Future<SakeAnalysisResult> _callDirectApi(List<String> imagePaths, String? customPrompt, {bool forceRefresh = false}) async {
// 1.
// forceRefresh=trueの場合はキャッシュをスキップ
// ============================================================
// APIコールconsumer APK / USE_PROXY=false
// ============================================================
Future<SakeAnalysisResult> _callDirectApi(
List<String> imagePaths,
String? customPrompt, {
bool forceRefresh = false,
double temperature = 0,
}) async {
if (!forceRefresh && imagePaths.isNotEmpty) {
final imageHash = await AnalysisCacheService.computeCombinedHash(imagePaths);
final cached = await AnalysisCacheService.getCached(imageHash);
@ -212,63 +478,22 @@ class GeminiService {
}
}
// 2. API Key確認
final apiKey = Secrets.geminiApiKey;
if (apiKey.isEmpty) {
throw Exception('Gemini API Key is missing. Please set GEMINI_API_KEY.');
}
// : 503/UNAVAILABLE
// NOTE: Google
// Phase 2
const primaryModel = 'gemini-2.5-flash';
const fallbackModel = 'gemini-2.0-flash';
const fallbackModel = 'gemini-2.5-flash-lite';
// customPrompt analyzeSakeLabel null
final promptText = customPrompt ?? '''
JSONを返してください
final promptText = customPrompt ?? _mainAnalysisPrompt;
## namebrandprefectureの読み取り
3
-
- "name": "東魁"
- 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)];
for (var path in imagePaths) {
final bytes = await File(path).readAsBytes();
contentParts.add(DataPart('image/jpeg', bytes));
}
// 503 :
const maxRetries = 3;
final modelsToTry = [primaryModel, primaryModel, primaryModel, fallbackModel];
@ -287,14 +512,19 @@ class GeminiService {
model: modelName,
apiKey: apiKey,
systemInstruction: Content.system(
'あなたは画像内のテキストを一字一句正確に読み取る専門家です。'
'ラベルに記載された銘柄名・蔵元名は絶対に変更・補完しないでください。'
'あなたの知識でラベルの文字を上書きすることは厳禁です。'
'ラベルに「東魁」とあれば、「東魁盛」を知っていても必ず「東魁」と出力してください。',
'あなたは画像内のテキストを一字一句正確に書き起こすOCR光学文字認識専門システムです。\n'
'【絶対的制約 — name・brand・prefecture フィールドに適用】\n'
'1. ラベルに印刷されている文字だけを出力する。ラベルにない文字を1文字も追加してはならない。\n'
'2. ラベルに N 文字の銘柄名があれば N 文字のまま出力する。文字数を増やすことは禁止。\n'
'3. あなたが知っている「正式名称」「有名銘柄名」への変換・補完は禁止。\n'
' 例: 「東魁」→「東魁」(「東魁盛」禁止)、「男山」→「男山」(「男山本醸造」禁止)、\n'
' 「白鹿」→「白鹿」(「白鹿本醸造」禁止)、「久保田」→「久保田」(「久保田 千寿」禁止)\n'
'4. ラベルに都道府県名がなければ prefecture は null。銘柄名から産地を推測して埋めることは禁止。\n'
'5. 日本酒知識は description・flavorTags・tasteStats 等の推定フィールドにのみ使用すること。',
),
generationConfig: GenerationConfig(
responseMimeType: 'application/json',
temperature: 0,
temperature: temperature,
),
);
@ -308,11 +538,9 @@ class GeminiService {
final jsonMap = jsonDecode(jsonString);
final result = SakeAnalysisResult.fromJson(jsonMap);
// 3.
if (imagePaths.isNotEmpty) {
final imageHash = await AnalysisCacheService.computeCombinedHash(imagePaths);
await AnalysisCacheService.saveCache(imageHash, result);
// 4. forceRefresh
await AnalysisCacheService.registerBrandIndex(
result.name,
imageHash,
@ -329,22 +557,20 @@ class GeminiService {
debugPrint('Direct API Error (attempt $attempt, model: $modelName): $e');
if (isLastAttempt || !is503) {
// or 503
if (is503) {
throw const GeminiCongestionException();
}
if (is503) throw const GeminiCongestionException();
throw Exception('AI解析エラー(Direct): $e');
}
// 503
}
}
//
throw Exception('AI解析に失敗しました。再試行してください。');
}
}
// Analysis Result Model
// ============================================================
// Data Models
// ============================================================
class SakeAnalysisResult {
final String? name;
final String? brand;
@ -356,7 +582,6 @@ class SakeAnalysisResult {
final List<String> flavorTags;
final Map<String, int> tasteStats;
// New Fields
final double? alcoholContent;
final int? polishingRatio;
final double? sakeMeterValue;
@ -365,7 +590,6 @@ class SakeAnalysisResult {
final String? manufacturingYearMonth;
/// EXP付与使
/// JSON false
final bool isFromCache;
SakeAnalysisResult({
@ -387,7 +611,6 @@ class SakeAnalysisResult {
this.isFromCache = false,
});
///
SakeAnalysisResult asCached() => SakeAnalysisResult(
name: name, brand: brand, prefecture: prefecture, type: type,
description: description, catchCopy: catchCopy, confidenceScore: confidenceScore,
@ -399,7 +622,6 @@ class SakeAnalysisResult {
);
factory SakeAnalysisResult.fromJson(Map<String, dynamic> json) {
// tasteStats: 3 (15)
const requiredStatKeys = ['aroma', 'sweetness', 'acidity', 'bitterness', 'body'];
Map<String, int> stats = {};
if (json['tasteStats'] is Map) {
@ -432,7 +654,6 @@ class SakeAnalysisResult {
);
}
/// JSON形式に変換
Map<String, dynamic> toJson() {
return {
'name': name,

View File

@ -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 booleanerror
if (data['revoked'] == true ||
(data['error'] as String? ?? '').contains('無効化')) {
return LicenseStatus.revoked;
}
return LicenseStatus.free;
} catch (e) {
@ -148,17 +191,21 @@ class LicenseService {
if (cached == null) return LicenseStatus.free;
// freeにフォールバック
// pro revoked proは購入者を締め出さないrevokedは誤って復活させない
// checkStatus
// _getCachedStatus
//
// TTL _cacheValidSeconds = 24h:
// - free / offline: free
// - pro :
// - revoked:
if (cachedAt != null) {
final age = DateTime.now().difference(DateTime.parse(cachedAt));
final isPermanentStatus = cached == LicenseStatus.pro.name || cached == LicenseStatus.revoked.name;
if (age.inSeconds > _cacheValidSeconds && !isPermanentStatus) {
final isNoExpiryStatus = cached == LicenseStatus.pro.name || cached == LicenseStatus.revoked.name;
if (age.inSeconds > _cacheValidSeconds && !isNoExpiryStatus) {
return LicenseStatus.free;
}
}
// Pro
return LicenseStatus.values.firstWhere(
(s) => s.name == cached,
orElse: () => LicenseStatus.free,

View File

@ -159,12 +159,10 @@ class SakenowaAutoMatchingService {
sakenowaFlavorChart: flavorChartMap,
);
// SakeItem更新
sakeItem.displayData = updatedDisplayData;
sakeItem.hiddenSpecs = updatedHiddenSpecs;
// Hiveに保存
await sakeItem.save();
await sakeItem.applyUpdates(
displayData: updatedDisplayData,
hiddenSpecs: updatedHiddenSpecs,
);
debugPrint(' [SakenowaAutoMatching] 適用完了!');
debugPrint(' displayName: ${sakeItem.displayData.displayName}');
@ -226,10 +224,10 @@ class SakenowaAutoMatchingService {
sakenowaFlavorChart: null,
);
sakeItem.displayData = clearedDisplayData;
sakeItem.hiddenSpecs = clearedHiddenSpecs;
await sakeItem.save();
await sakeItem.applyUpdates(
displayData: clearedDisplayData,
hiddenSpecs: clearedHiddenSpecs,
);
debugPrint(' [SakenowaAutoMatching] クリア完了');
}

View File

@ -52,7 +52,12 @@ class _AddSetItemDialogState extends ConsumerState<AddSetItemDialog> {
});
}
} catch (e) {
// Handle error
debugPrint('Image pick error: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('画像の選択に失敗しました。再度お試しください。')),
);
}
}
}
@ -104,7 +109,7 @@ class _AddSetItemDialogState extends ConsumerState<AddSetItemDialog> {
final newlyUnlockedBadges = await GamificationService.checkAndUnlockBadges(ref);
if (newlyUnlockedBadges.isNotEmpty) {
debugPrint('🏅 Badges Unlocked: ${newlyUnlockedBadges.map((b) => b.name).join(", ")}');
debugPrint('[Gamification] Badges Unlocked: ${newlyUnlockedBadges.map((b) => b.name).join(", ")}');
}
if (mounted) {

View File

@ -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),
),
),
],
],
),
),

View File

@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lucide_icons/lucide_icons.dart';
import '../../providers/sake_list_provider.dart';
import '../../models/schema/item_type.dart';
import '../../services/api_usage_service.dart';
import '../../theme/app_colors.dart';
class ActivityStats extends ConsumerWidget {
@ -12,6 +13,8 @@ class ActivityStats extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final allSakeAsync = ref.watch(allSakeItemsProvider);
final apiUsageAsync = ref.watch(apiUsageCountProvider);
return allSakeAsync.when(
data: (sakes) {
final individualSakes = sakes.where((s) => s.itemType == ItemType.sake).toList();
@ -25,7 +28,17 @@ class ActivityStats extends ConsumerWidget {
}).toSet();
final recordingDays = dates.length;
final apiCount = apiUsageAsync.asData?.value ?? 0;
final remaining = (ApiUsageService.dailyLimit - apiCount).clamp(0, ApiUsageService.dailyLimit);
final isExhausted = remaining == 0;
final isLow = remaining <= 5 && !isExhausted;
final appColors = Theme.of(context).extension<AppColors>()!;
final apiColor = isExhausted
? appColors.error
: isLow
? appColors.warning
: appColors.brandPrimary;
// Bento Grid: 2
return Column(
@ -134,6 +147,57 @@ class ActivityStats extends ConsumerWidget {
),
],
),
const SizedBox(height: 8),
// AI 使
_BentoCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(LucideIcons.sparkles, size: 14, color: apiColor),
const SizedBox(width: 6),
Text(
'今日のAI解析',
style: TextStyle(fontSize: 11, color: appColors.textSecondary),
),
const Spacer(),
Text(
'$apiCount / ${ApiUsageService.dailyLimit}',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: apiColor,
),
),
],
),
const SizedBox(height: 8),
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value: apiCount / ApiUsageService.dailyLimit,
backgroundColor: appColors.surfaceSubtle,
valueColor: AlwaysStoppedAnimation<Color>(apiColor),
minHeight: 5,
),
),
if (isExhausted) ...[
const SizedBox(height: 6),
Text(
'本日の上限に達しました。写真は保存されています。',
style: TextStyle(fontSize: 10, color: appColors.error),
),
] else if (isLow) ...[
const SizedBox(height: 6),
Text(
'残り$remaining回です',
style: TextStyle(fontSize: 10, color: appColors.warning),
),
],
],
),
),
],
);
},

View File

@ -188,6 +188,8 @@ class SakeListItem extends ConsumerWidget {
if (!isMenuMode && sake.itemType != ItemType.set) ...[
const SizedBox(height: 6),
_buildSpecLine(context, appColors),
const SizedBox(height: 4),
_buildRecordedDate(appColors),
],
],
),
@ -200,6 +202,36 @@ class SakeListItem extends ConsumerWidget {
); // Pressable
}
///
Widget _buildRecordedDate(AppColors appColors) {
final date = sake.metadata.createdAt;
final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day);
final itemDay = DateTime(date.year, date.month, date.day);
final diff = today.difference(itemDay).inDays;
final String label;
if (diff == 0) {
label = '今日';
} else if (diff == 1) {
label = '昨日';
} else if (diff < 7) {
label = '$diff日前';
} else if (date.year == now.year) {
label = '${date.month}/${date.day}';
} else {
label = '${date.year}/${date.month}/${date.day}';
}
return Text(
label,
style: TextStyle(
fontSize: 10,
color: appColors.textTertiary,
),
);
}
/// +
Widget _buildSpecLine(BuildContext context, AppColors appColors) {
final type = sake.hiddenSpecs.type;

View File

@ -2,19 +2,21 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../providers/filter_providers.dart';
import 'package:lucide_icons/lucide_icons.dart';
import '../../theme/app_colors.dart';
class SakeNoMatchState extends ConsumerWidget {
const SakeNoMatchState({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final appColors = Theme.of(context).extension<AppColors>()!;
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(LucideIcons.filterX, size: 48, color: Colors.grey[400]),
Icon(LucideIcons.filterX, size: 48, color: appColors.textTertiary),
const SizedBox(height: 16),
Text('条件に一致するお酒が見つかりません', style: TextStyle(color: Colors.grey[600])),
Text('条件に一致するお酒が見つかりません', style: TextStyle(color: appColors.textTertiary)),
TextButton(
child: const Text('フィルタを解除'),
onPressed: () {

View File

@ -76,7 +76,7 @@ class PrefectureTileMap extends ConsumerWidget {
if (v.contains(prefName)) prefId = k;
});
final regionId = JapanMapData.getRegionId(prefId);
final regionColor = regionColors[regionId] ?? Colors.grey;
final regionColor = regionColors[regionId] ?? appColors.divider;
Color baseColor;
Color textColor;

View File

@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lucide_icons/lucide_icons.dart';
import '../services/draft_service.dart';
import '../screens/pending_analysis_screen.dart';
import '../theme/app_colors.dart';
/// Draft
///
@ -24,19 +25,21 @@ class PendingAnalysisBanner extends ConsumerWidget {
return const SizedBox.shrink();
}
final appColors = Theme.of(context).extension<AppColors>()!;
return Container(
margin: const EdgeInsets.all(12),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
Colors.orange.shade600,
Colors.orange.shade400,
appColors.warning,
appColors.warning.withValues(alpha: 0.85),
],
),
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.orange.withValues(alpha: 0.3),
color: appColors.warning.withValues(alpha: 0.3),
blurRadius: 8,
offset: const Offset(0, 2),
),
@ -98,7 +101,7 @@ class PendingAnalysisBanner extends ConsumerWidget {
child: Text(
'$pendingCount件',
style: TextStyle(
color: Colors.orange.shade700,
color: appColors.warning,
fontSize: 14,
fontWeight: FontWeight.bold,
),

View File

@ -109,6 +109,10 @@ class _Sake3DCarouselState extends State<Sake3DCarousel> {
? Image.file(
File(item.displayData.imagePaths.first),
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) => Container(
color: Colors.grey[300],
child: const Icon(Icons.broken_image, size: 50, color: Colors.grey),
),
)
: Container(
color: Colors.grey[300],

View File

@ -164,6 +164,10 @@ class _Sake3DCarouselWithReasonState extends State<Sake3DCarouselWithReason> {
? Image.file(
File(rec.item.displayData.imagePaths.first),
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) => Container(
color: Colors.grey[300],
child: Icon(LucideIcons.imageOff, size: 50, color: Colors.grey[600]),
),
)
: Container(
color: Colors.grey[300],

View File

@ -75,9 +75,9 @@ class _SakeDetailSpecsState extends State<SakeDetailSpecs> {
if (_isEditing) {
// Warn user about external update while editing
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('データが外部から更新されました。編集をキャンセルして再度お試しください。'),
backgroundColor: Colors.orange,
SnackBar(
content: const Text('データが外部から更新されました。編集をキャンセルして再度お試しください。'),
backgroundColor: Theme.of(context).extension<AppColors>()!.warning,
),
);
_cancel(); // Force exit edit mode
@ -272,6 +272,7 @@ class _SakeDetailSpecsState extends State<SakeDetailSpecs> {
_isEditing,
suffixIcon: LucideIcons.calendar,
onSuffixTap: () => _showDatePicker(context),
helperText: '例: 2023-10',
),
],
),
@ -303,10 +304,11 @@ class _SakeDetailSpecsState extends State<SakeDetailSpecs> {
void _showDatePicker(BuildContext context) {
if (!_isEditing) return;
// Parse current value or use now
// Parse current value or use now (AI出力 "2023.10" "2023-10" )
DateTime initialDate = DateTime.now();
try {
final parts = _manufacturingController.text.split('-');
final normalized = _manufacturingController.text.replaceAll('.', '-');
final parts = normalized.split('-');
if (parts.length >= 2) {
final year = int.parse(parts[0]);
final month = int.parse(parts[1]);

View File

@ -70,7 +70,12 @@ class SakeSearchDelegate extends SearchDelegate {
width: 40,
height: 40,
child: sake.displayData.imagePaths.isNotEmpty
? Image.file(File(sake.displayData.imagePaths.first), fit: BoxFit.cover)
? Image.file(
File(sake.displayData.imagePaths.first),
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) =>
const Icon(LucideIcons.imageOff),
)
: const Icon(LucideIcons.image),
),
title: Text(sake.displayData.displayName),

View File

@ -286,6 +286,7 @@ class _SakenowaRankingSectionState extends ConsumerState<SakenowaRankingSection>
Image.file(
File(item.userImagePath!),
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) => const SizedBox.shrink(),
)
else
Container(

View File

@ -1,9 +1,12 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lucide_icons/lucide_icons.dart';
import '../../providers/sake_list_provider.dart';
import '../../providers/theme_provider.dart';
import '../../services/backup_service.dart';
import '../../theme/app_colors.dart';
class BackupSettingsSection extends StatefulWidget {
class BackupSettingsSection extends ConsumerStatefulWidget {
final String title;
const BackupSettingsSection({
@ -12,12 +15,12 @@ class BackupSettingsSection extends StatefulWidget {
});
@override
State<BackupSettingsSection> createState() => _BackupSettingsSectionState();
ConsumerState<BackupSettingsSection> createState() => _BackupSettingsSectionState();
}
enum _BackupState { idle, signingIn, signingOut, backingUp, restoring }
class _BackupSettingsSectionState extends State<BackupSettingsSection> {
class _BackupSettingsSectionState extends ConsumerState<BackupSettingsSection> {
final BackupService _backupService = BackupService();
_BackupState _state = _BackupState.idle;
@ -28,7 +31,11 @@ enum _BackupState { idle, signingIn, signingOut, backingUp, restoring }
}
Future<void> _initBackupService() async {
try {
await _backupService.init();
} catch (e) {
debugPrint('[Backup] Init error (silent sign-in failed): $e');
}
if (mounted) {
setState(() {});
}
@ -86,6 +93,7 @@ enum _BackupState { idle, signingIn, signingOut, backingUp, restoring }
Future<void> _createBackup() async {
final messenger = ScaffoldMessenger.of(context);
final appColors = Theme.of(context).extension<AppColors>()!;
setState(() => _state = _BackupState.backingUp);
final success = await _backupService.createBackup();
if (mounted) {
@ -93,11 +101,7 @@ enum _BackupState { idle, signingIn, signingOut, backingUp, restoring }
messenger.showSnackBar(
SnackBar(
content: Text(success ? 'バックアップが完了しました' : 'バックアップに失敗しました'),
// Snackbars can keep Green/Red for semantic clarity, or be neutral.
// User asked to remove Green/Red icons from the UI, but feedback (Snackbar) usually stays semantic.
// However, to be safe and "Washi", let's use Sumi (Black) for success?
// Or just leave snackbars as they are ephemeral. The request was likely about the visible static UI.
backgroundColor: success ? Colors.green : Colors.red,
backgroundColor: success ? appColors.success : appColors.error,
),
);
}
@ -135,7 +139,7 @@ enum _BackupState { idle, signingIn, signingOut, backingUp, restoring }
Future<void> _restoreBackup() async {
final messenger = ScaffoldMessenger.of(context);
final appColors = Theme.of(context).extension<AppColors>()!;
// Note: hasBackup check is async
final hasBackup = await _backupService.hasBackupOnDrive();
if (!hasBackup) {
if (mounted) {
@ -176,18 +180,77 @@ enum _BackupState { idle, signingIn, signingOut, backingUp, restoring }
),
);
if (confirmed == true && mounted) {
if (confirmed != true || !mounted) return;
await _executeRestore(forceSkipPreBackup: false);
}
/// PreRestoreBackupException
Future<void> _executeRestore({required bool forceSkipPreBackup}) async {
final messenger = ScaffoldMessenger.of(context);
final appColors = Theme.of(context).extension<AppColors>()!;
setState(() => _state = _BackupState.restoring);
final success = await _backupService.restoreBackup();
try {
final success = forceSkipPreBackup
? await _backupService.restoreBackupSkippingPreBackup()
: await _backupService.restoreBackup();
if (mounted) {
setState(() => _state = _BackupState.idle);
if (success) {
ref.invalidate(rawSakeListItemsProvider);
ref.invalidate(sakeSortOrderProvider);
ref.invalidate(userProfileProvider);
}
messenger.showSnackBar(
SnackBar(
content: Text(success ? '復元が完了しました' : '復元に失敗しました'),
backgroundColor: success ? Colors.green : Colors.red,
backgroundColor: success ? appColors.success : appColors.error,
),
);
}
} on PreRestoreBackupException {
if (!mounted) return;
setState(() => _state = _BackupState.idle);
//
final continueAnyway = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Row(
children: [
Icon(LucideIcons.alertTriangle, color: appColors.error, size: 24),
const SizedBox(width: 8),
const Text('安全バックアップに失敗'),
],
),
content: const Text(
'復元前の安全コピー作成に失敗しました。\n'
'このまま続行すると、現在のデータが失われた場合に\n'
'元に戻せない可能性があります。\n\n'
'続行しますか?',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('中断'),
),
ElevatedButton(
onPressed: () => Navigator.pop(context, true),
style: ElevatedButton.styleFrom(
backgroundColor: appColors.error,
foregroundColor: appColors.surfaceSubtle,
),
child: const Text('それでも続行'),
),
],
),
);
if (continueAnyway == true && mounted) {
await _executeRestore(forceSkipPreBackup: true);
}
}
}

View File

@ -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:

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
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 1.0.38+45
version: 1.0.49+56
environment:
sdk: ^3.10.1
@ -35,8 +35,8 @@ dependencies:
# Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^1.0.8
google_fonts: ^6.3.3
flutter_riverpod:
riverpod_annotation:
flutter_riverpod: ^3.1.0
riverpod_annotation: 3.0.0-dev.3
hive: ^2.2.3
hive_flutter: ^1.1.0
google_generative_ai: ^0.4.7
@ -46,7 +46,7 @@ dependencies:
device_info_plus: ^10.1.0
http: ^1.2.0
crypto: ^3.0.3 # SHA-256 ハッシュ計算用(キャッシュ)
lucide_icons: ^0.257.0
lucide_icons: 0.257.0
reorderable_grid_view: ^2.2.5
camera: ^0.11.3
path_provider: ^2.1.5
@ -61,6 +61,7 @@ dependencies:
package_info_plus: ^8.1.2
gal: ^2.3.0
shared_preferences: ^2.5.4
flutter_secure_storage: ^9.2.2
# Phase 9: Google Drive Backup
googleapis: ^13.2.0
@ -85,7 +86,7 @@ dev_dependencies:
flutter_lints: ^6.0.0
build_runner:
hive_generator:
riverpod_generator:
riverpod_generator: 3.0.0-dev.11
flutter_launcher_icons: ^0.13.1
flutter_native_splash: ^2.4.4

View File

@ -137,6 +137,57 @@ void main() {
});
});
group('SakeItem - ensureMigrated', () {
test('レガシーフィールドからdisplayDataを構築する', () {
final sake = SakeItem(
id: 'legacy-001',
legacyName: '出羽桜',
legacyBrand: '出羽桜酒造',
legacyPrefecture: '山形県',
legacyCatchCopy: '花と夢と',
legacyImagePaths: ['/path/legacy.jpg'],
);
// displayData未設定の状態でgetterがlegacyから返す
expect(sake.displayData.name, '出羽桜');
expect(sake.displayData.brewery, '出羽桜酒造');
expect(sake.displayData.prefecture, '山形県');
// ensureMigratedで新構造に昇格
final migrated = sake.ensureMigrated();
expect(migrated, true); //
// 2falseを返す
final again = sake.ensureMigrated();
expect(again, false);
});
test('displayDataが既に設定されている場合はfalseを返す', () {
final sake = SakeItem(
id: 'new-001',
displayData: DisplayData(
name: '新政',
brewery: '新政酒造',
prefecture: '秋田県',
imagePaths: [],
),
);
final migrated = sake.ensureMigrated();
expect(migrated, false);
});
test('legacyNameがnullの場合はUnknownにフォールバックする', () {
final sake = SakeItem(id: 'legacy-002');
sake.ensureMigrated();
expect(sake.displayData.name, 'Unknown');
expect(sake.displayData.brewery, 'Unknown');
expect(sake.displayData.prefecture, 'Unknown');
});
});
group('SakeItem - HiddenSpecs / TasteStats', () {
test('should return SakeTasteStats from tasteStats map', () {
final sake = SakeItem(

View File

@ -0,0 +1,158 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:ponshu_room_lite/services/gemini_service.dart';
void main() {
group('SakeAnalysisResult.fromJson', () {
test('正常なJSONから全フィールドを正しくパースする', () {
final json = {
'name': '獺祭',
'brand': '旭酒造',
'prefecture': '山口県',
'type': '純米大吟醸',
'description': 'フルーティーで華やかな香り',
'catchCopy': '磨きその先へ',
'confidenceScore': 92,
'flavorTags': ['フルーティー', '辛口'],
'tasteStats': {'aroma': 5, 'sweetness': 2, 'acidity': 3, 'bitterness': 2, 'body': 3},
'alcoholContent': 16.0,
'polishingRatio': 23,
'sakeMeterValue': 3.0,
'riceVariety': '山田錦',
'yeast': 'きょうかい9号',
'manufacturingYearMonth': '2024.01',
};
final result = SakeAnalysisResult.fromJson(json);
expect(result.name, '獺祭');
expect(result.brand, '旭酒造');
expect(result.prefecture, '山口県');
expect(result.type, '純米大吟醸');
expect(result.confidenceScore, 92);
expect(result.flavorTags, ['フルーティー', '辛口']);
expect(result.alcoholContent, 16.0);
expect(result.polishingRatio, 23);
expect(result.riceVariety, '山田錦');
expect(result.manufacturingYearMonth, '2024.01');
expect(result.isFromCache, false);
});
test('フィールドが全てnullの場合にデフォルト値が設定される', () {
final result = SakeAnalysisResult.fromJson({});
expect(result.name, isNull);
expect(result.brand, isNull);
expect(result.prefecture, isNull);
expect(result.flavorTags, isEmpty);
expect(result.isFromCache, false);
});
test('tasteStats: 範囲外の値(0, 6)が1〜5にクランプされる', () {
final json = {
'tasteStats': {
'aroma': 0,
'sweetness': 6,
'acidity': -1,
'bitterness': 10,
'body': 3,
},
};
final result = SakeAnalysisResult.fromJson(json);
expect(result.tasteStats['aroma'], 1);
expect(result.tasteStats['sweetness'], 5);
expect(result.tasteStats['acidity'], 1);
expect(result.tasteStats['bitterness'], 5);
expect(result.tasteStats['body'], 3);
});
test('tasteStats: 一部キーが欠損していると3で補完される', () {
final json = {
'tasteStats': {
'aroma': 5,
// sweetness, acidity, bitterness, body
},
};
final result = SakeAnalysisResult.fromJson(json);
expect(result.tasteStats['aroma'], 5);
expect(result.tasteStats['sweetness'], 3);
expect(result.tasteStats['acidity'], 3);
expect(result.tasteStats['bitterness'], 3);
expect(result.tasteStats['body'], 3);
});
test('tasteStats: nullまたは不正な型の場合は全キーが3になる', () {
final json = {'tasteStats': null};
final result = SakeAnalysisResult.fromJson(json);
expect(result.tasteStats['aroma'], 3);
expect(result.tasteStats['sweetness'], 3);
expect(result.tasteStats['body'], 3);
});
test('alcoholContent: intで渡された場合もdoubleとして取得できる', () {
final json = {'alcoholContent': 15};
final result = SakeAnalysisResult.fromJson(json);
expect(result.alcoholContent, 15.0);
});
test('flavorTags: nullの場合は空リストになる', () {
final json = {'flavorTags': null};
final result = SakeAnalysisResult.fromJson(json);
expect(result.flavorTags, isEmpty);
});
});
group('SakeAnalysisResult.asCached', () {
test('asCached()はisFromCache=trueを返す', () {
final original = SakeAnalysisResult(name: '獺祭', brand: '旭酒造');
final cached = original.asCached();
expect(cached.isFromCache, true);
expect(cached.name, '獺祭');
expect(cached.brand, '旭酒造');
});
test('元のインスタンスはisFromCache=falseを維持する', () {
final original = SakeAnalysisResult(name: '久保田');
original.asCached();
expect(original.isFromCache, false);
});
});
group('SakeAnalysisResult.toJson / fromJson 往復', () {
test('toJson → fromJson で値が保持される', () {
final original = SakeAnalysisResult(
name: '八海山',
brand: '八海醸造',
prefecture: '新潟県',
type: '特別本醸造',
confidenceScore: 80,
flavorTags: ['辛口', 'すっきり'],
tasteStats: {'aroma': 2, 'sweetness': 2, 'acidity': 3, 'bitterness': 3, 'body': 3},
alcoholContent: 15.5,
polishingRatio: 55,
);
final json = original.toJson();
final restored = SakeAnalysisResult.fromJson(json);
expect(restored.name, original.name);
expect(restored.brand, original.brand);
expect(restored.prefecture, original.prefecture);
expect(restored.confidenceScore, original.confidenceScore);
expect(restored.flavorTags, original.flavorTags);
expect(restored.alcoholContent, original.alcoholContent);
expect(restored.polishingRatio, original.polishingRatio);
});
test('toJson に isFromCache は含まれない', () {
final result = SakeAnalysisResult(name: 'テスト').asCached();
final json = result.toJson();
expect(json.containsKey('isFromCache'), false);
});
});
}

View File

@ -1,20 +1,20 @@
{
"date": "2026-04-16",
"name": "Ponshu Room 1.0.37 (2026-04-16)",
"version": "v1.0.37",
{
"version": "v1.0.49",
"name": "Ponshu Room 1.0.49 (2026-04-23)",
"date": "2026-04-23",
"apks": {
"eiji": {
"lite": {
"url": "https://posimai-lab.tail72e846.ts.net/mai/ponshu-room-lite/releases/download/v1.0.37/ponshu_room_consumer_eiji.apk",
"size_mb": 89.1,
"filename": "ponshu_room_consumer_eiji.apk"
}
},
"maita": {
"lite": {
"url": "https://posimai-lab.tail72e846.ts.net/mai/ponshu-room-lite/releases/download/v1.0.37/ponshu_room_consumer_maita.apk",
"size_mb": 89.1,
"filename": "ponshu_room_consumer_maita.apk"
"filename": "ponshu_room_consumer_maita.apk",
"url": "https://posimai-lab.tail72e846.ts.net/mai/ponshu-room-lite/releases/download/v1.0.49/ponshu_room_consumer_maita.apk",
"size_mb": 91
}
},
"eiji": {
"lite": {
"filename": "ponshu_room_consumer_eiji.apk",
"url": "https://posimai-lab.tail72e846.ts.net/mai/ponshu-room-lite/releases/download/v1.0.49/ponshu_room_consumer_eiji.apk",
"size_mb": 91
}
}
}

View File

@ -51,7 +51,7 @@
<!-- Download Section -->
<section class="download">
<div class="version-cards">
<!-- Lite Version -->
<!-- Lite Version (Eiji) -->
<a href="/releases/ponshu-room-lite.apk" class="version-card" download>
<div class="card-header">
<span class="version-name">Lite</span>
@ -62,21 +62,21 @@
<path d="M12 3v12m0 0l-4-4m4 4l4-4M5 17v2a2 2 0 002 2h10a2 2 0 002-2v-2" />
</svg>
</div>
<span class="file-size">88 MB</span>
<span class="file-size">90 MB</span>
</a>
<!-- Pro Version -->
<a href="/releases/ponshu-room-pro.apk" class="version-card pro" download>
<!-- Maita Version -->
<a href="/releases/ponshu-room-maita.apk" class="version-card" download>
<div class="card-header">
<span class="version-name">Pro</span>
<span class="version-badge pro-badge">Pro</span>
<span class="version-name">Maita</span>
<span class="version-badge free">Dev</span>
</div>
<div class="card-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M12 3v12m0 0l-4-4m4 4l4-4M5 17v2a2 2 0 002 2h10a2 2 0 002-2v-2" />
</svg>
</div>
<span class="file-size">89 MB</span>
<span class="file-size">90 MB</span>
</a>
</div>
@ -116,7 +116,7 @@
<!-- Footer -->
<footer>
<p>v1.0.12</p>
<p>v1.0.44</p>
</footer>
</div>
</body>

Binary file not shown.

Binary file not shown.

View File

@ -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(

View File

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