feat: enhance AI spec extraction

This commit is contained in:
Ponshu Developer 2026-01-16 00:53:44 +09:00
parent a5353a9b50
commit 6507ab9596
36 changed files with 623 additions and 1122 deletions

View File

@ -18,7 +18,8 @@
"Bash(flutter build:*)",
"Bash(unzip:*)",
"Bash(ls:*)",
"Bash(awk:*)"
"Bash(awk:*)",
"Bash(flutter pub:*)"
],
"deny": [],
"ask": []

View File

@ -1,510 +0,0 @@
# ぽんるーむ (Pon-Room) - 共通仕様書 v1.0
**作成日**: 2026-01-03
**対象**: AI開発エージェントCursor/Antigravity/Claude/Gemini
**目的**: すべての開発者・AIエージェントが参照する唯一のバイブル
---
## 📋 1. プロジェクト概要
### 1.1 アプリケーション情報
- **アプリ名**: ぽんるーむ (Pon-Room)
- **コンセプト**: 日本酒の「記録・解析・循環」を支えるAIプラットフォーム
- **バージョン**: 1.0.9+47
- **最終更新**: 2026-01-03
### 1.2 ターゲットユーザー
- **B2C (Consumer Mode)**: 一般ユーザー
- 日本酒体験の記録・診断・共有
- ゲーミフィケーション(ポイント・バッジ・レベル)
- 「酒向(しゅこう)カード」による自己表現
- **B2B (Business Mode)**: 飲食店
- 自店の日本酒メニュー作成PDF出力
- QRコード自動埋め込み
- インスタ投稿支援
### 1.3 アプリの循環ロジック
```
[飲食店] PDF + QRメニュー作成
[客] スキャンして詳細表示・ポイント獲得
[客] 自分の記録が貯まる → 酒向カード生成
[飲食店] 客の好みを理解してレコメンド
```
---
## 🛠️ 2. 技術スタック
### 2.1 フロントエンド
- **Framework**: Flutter 3.10.1
- **SDK**: Dart 3.10.1
- **対応プラットフォーム**: iOS, Android, Web
### 2.2 バックエンド・データベース
- **Database**: Cloud Firestore (Firebase)
- **Authentication**: Firebase Auth
- **Storage**: Firebase Storage (画像保存)
### 2.3 AI・機械学習
- **Local OCR**: Google ML Kit
- 用途: ラベルからのテキスト抽出
- メリット: 無料、高速、オフライン動作
- **LLM Analysis**: Gemini API
- モデル: `gemini-2.5-flash` / `gemini-3.0-flash`
- 用途: テキストの構造化、キャッチコピー生成
- 重要: 画像を直接送らず、OCRテキストのみ送信トークン節約
### 2.4 主要パッケージ
```yaml
dependencies:
# 画像・カメラ
image_picker: ^1.2.1
gal: ^2.3.0 # カメラロール保存
# PDF生成
pdf: ^3.10.0
printing: ^5.11.0
# QRコード
qr_flutter: ^4.1.0
mobile_scanner: ^3.5.0 # QRスキャン
# データベース
hive: ^2.2.3
hive_flutter: ^1.1.0
# AI
google_generative_ai: ^0.4.7
google_ml_kit: ^0.16.0 # OCR用
```
---
## 📊 3. 共通データ構造JSON Schema
### 3.1 基本方針
- **display_data**: カードUIで表示する最小限の情報シンプル維持
- **hidden_specs**: 詳細画面・PDF・分析で使用する情報
- **badges**: ゲーミフィケーション要素
- **gamification**: ポイント・レベル・診断結果
- **user_data**: ユーザーの主観的情報
- **metadata**: システム管理用
### 3.2 SakeItem完全JSON構造
```json
{
"display_data": {
"name": "獺祭 純米大吟醸 磨き二割三分",
"catch_phrase": "華やかな香りと洗練された味わい",
"image_path": "path/to/image.jpg",
"rating": 4.5
},
"hidden_specs": {
"brewery": "旭酒造株式会社",
"prefecture": "山口県",
"type": "純米大吟醸",
"alcohol_content": 16.0,
"polishing_ratio": 23,
"sake_meter_value": 4.0,
"rice_variety": "山田錦",
"yeast": "自社酵母",
"manufacturing_year_month": "2024.10",
"qr_code_url": "https://pon-room.app/sake/abc123"
},
"badges": {
"is_recommended": false,
"is_seasonal": false,
"season_tag": "春限定"
},
"gamification": {
"pon_points": 10,
"sake_mbti_type": "フルーティー・モダン型",
"rarity_level": "レア"
},
"user_data": {
"is_favorite": false,
"is_wishlist": false,
"tags": ["甘口", "フルーティー", "冷酒向き"],
"memo": "お祝い事にぴったり。冷やして飲むのがおすすめ。",
"drink_location": "○○レストラン",
"companion": "△△さん",
"purchase_location": "××酒販店",
"price": 5000
},
"metadata": {
"created_at": "2026-01-03T12:34:56Z",
"updated_at": "2026-01-03T12:34:56Z",
"app_type": "sake",
"app_mode": "consumer",
"version": "1.0",
"scanned_count": 0
}
}
```
---
## 🎨 4. UI構成ルール
### 4.1 タブ構成5タブ制
| タブ | Consumer Mode (B2C) | Business Mode (B2B) |
|------|---------------------|---------------------|
| **タブ1** | 日本酒カードリスト(自分の記録) | 日本酒カードリスト(店舗在庫) |
| **タブ2** | QRスキャン・AR情報表示 | PDFメニュー作成・QR埋め込み |
| **タブ3** | AIソムリエ・酒向カード診断 | インスタ投稿支援AI文章生成 |
| **タブ4** | 酒蔵マップ(聖地巡礼) | 店舗設定・在庫管理 |
| **タブ5** | マイページ(レベル・バッジ・設定) | アナリティクス(スキャン統計) |
### 4.2 カード表示ルール(重要)
**表示項目display_dataのみ:**
- 銘柄名 (`display_data.name`)
- 画像 (`display_data.image_path`)
- 評価 (`display_data.rating`) - 星表示
- バッジ (`badges`) - 店長推奨・季節限定アイコン
**非表示項目(詳細画面でのみ使用):**
- `hidden_specs` の全項目
- `user_data` の詳細情報
**理由:** シンプルなUIを維持し、挫折を防ぐ
### 4.3 詳細画面の表示項目
**全て表示:**
- `display_data` 全項目
- `hidden_specs` 全項目(スペック表として)
- `badges` (アイコン付き)
- `user_data` 全項目
### 4.4 PDF出力の表示項目
**主要項目:**
- `display_data.name`, `image_path`
- `hidden_specs` の以下:
- type, alcohol_content, polishing_ratio
- sake_meter_value, brewery, prefecture
- `badges` (アイコン付き)
- QRコード`hidden_specs.qr_code_url` から生成)
---
## 🤖 5. AI解析フローHybrid Analysis
### 5.1 撮影から保存まで
```
1. 撮影
├─ ImagePicker or Camera パッケージ
└─ カメラロール自動保存gal パッケージ使用)★重要
2. OCRテキスト抽出
├─ Google ML Kitローカル・無料
├─ 画像 → 全テキスト抽出
└─ 抽出テキストのみを次へ
3. AI解析構造化
├─ 抽出テキストを Gemini API へ送信
├─ プロンプト: 「JSONフォーマットで返して」
└─ 上記のJSON構造で回答を取得
4. 保存
├─ Hiveローカルに即座保存
└─ Firebaseクラウドに同期将来実装
```
### 5.2 AI解析プロンプト例
```
あなたは日本酒のラベル解析の専門家です。
以下のOCRテキストから、日本酒の情報を抽出してJSON形式で回答してください。
【OCRテキスト】
{ocrText}
【出力ルール】
1. display_data: 銘柄名と魅力的なキャッチコピー(あなたが生成)
2. hidden_specs: 詳細スペック(読み取れた範囲で)
3. 読み取れない項目はnullにする
4. JSONのみを返す```jsonなどのマークダウン記法は不要
5. キャッチコピーは20文字以内で簡潔に
【出力フォーマット】
{JSON構造をここに記載}
```
### 5.3 コスト最適化のポイント
**❌ NG: 画像を直接Geminiに送信**
- 高トークン消費
- エラー発生率が高い
**✅ OK: OCRテキストのみ送信**
- トークン消費70-90%削減
- 安定した動作
- 後から再解析が不要
---
## 🎮 6. ゲーミフィケーション機能
### 6.1 ポンポイント(仮称)
**獲得条件:**
- 日本酒を記録: +5pt
- QRスキャン: +10pt
- 酒蔵訪問: +30pt位置情報連動
- 評価・レビュー投稿: +3pt
**レベルシステム:**
```
0-49pt: 利き酒初心者
50-149pt: 日本酒愛好家
150-299pt: 酒豪
300-499pt: 利き酒師
500pt+: 酒マスター
```
### 6.2 酒向(しゅこう)カード
**概要:** MBTIライクな自己診断カード
**診断軸(レーダーチャート):**
1. 甘口 ←→ 辛口
2. 濃醇 ←→ 淡麗
3. フルーティー ←→ 米の旨味
4. 冷酒 ←→ 熱燗
**表示項目:**
- 診断タイプ(例: フルーティー・モダン型)
- レーダーチャート
- 好きな銘柄TOP3
- AIの一言コメント
**用途:**
- 店員に見せて好みを伝える
- SNSシェア画像として保存
---
## 📱 7. QR循環ロジック
### 7.1 BtoB: PDFメニュー作成時
```
1. 飲食店が店舗在庫を登録
2. PDFメニュー生成時、各銘柄にQRコード埋め込み
3. QRコード内容: https://pon-room.app/sake/{sakeId}
4. 印刷して店内に設置
```
### 7.2 BtoC: QRスキャン時
```
1. 客がアプリでQRスキャン
2. 銘柄詳細をAR風に表示オーバーレイ
3. 「記録する」ボタンで自分のリストに追加
4. ポンポイント+10pt獲得
5. 酒蔵リンクへの誘導
```
### 7.3 循環の価値
- **客**: スキャンする度にポイントが貯まる
- **店**: 客の興味データが蓄積(アナリティクス)
- **蔵元**: ECサイトへの誘導で売上向上
---
## 🎓 8. オンボーディング(使い方ガイド)
### 8.1 初回起動時
**4ステップガイド:**
1. ようこそ!日本酒の記録を始めよう
2. カメラで撮影するだけでAIが自動解析
3. お気に入りを記録して自分だけのコレクションを
4. 飲食店の方はBusinessモードへ切り替え可能
### 8.2 再表示機能
- 各画面右上の「?」アイコン
- タップでガイドを再表示
### 8.3 Businessモード専用ガイド
**3ステップモード切り替え時のみ表示:**
1. 店舗情報を設定しましょう
2. メニューを作成してPDF出力
3. QRコードで客とつながる
---
## 🚀 9. 開発優先順位Phase別
### Phase 0: 基盤整備 ✅
- [x] CommonSpecification.md 作成
- [ ] SakeItemモデルの拡張
### Phase 1: 安心の確保1-2時間
- [ ] カメラロール保存実装gal
- [ ] iOS/Android権限設定
- [ ] 撮影後の自動保存フロー
### Phase 2: BtoB機能完成4-6時間
- [ ] PDF + printing 実装
- [ ] モックアップ厳密再現
- [ ] QRコード埋め込み
- [ ] レイアウト定数化
### Phase 3: AI最適化3-4時間
- [ ] Google ML Kit 導入
- [ ] OCRテキスト抽出
- [ ] Gemini APIへのテキスト送信切り替え
### Phase 4: ゲーミフィケーション後回しOK
- [ ] ポンポイントシステム
- [ ] 酒向カード生成
- [ ] QRスキャン機能
---
## 📐 10. レイアウト定数PDF用
```dart
class PDFLayoutConstants {
// 余白
static const double pageMargin = 24.0;
static const double sectionSpacing = 20.0;
static const double itemSpacing = 12.0;
// フォントサイズ
static const double titleFontSize = 24.0;
static const double headingFontSize = 14.0;
static const double bodyFontSize = 11.0;
static const double labelFontSize = 9.0;
// 線の太さ
static const double borderWidthThin = 0.5;
static const double borderWidthMedium = 1.0;
static const double borderWidthThick = 2.0;
// 色posimaiカラー
static final PdfColor borderColor = PdfColor.fromHex('#E2E8F0');
static final PdfColor labelColor = PdfColor.fromHex('#64748B');
static final PdfColor accentColor = PdfColor.fromHex('#376495');
}
```
---
## 🔐 11. セキュリティ・プライバシー
### 11.1 データ保存場所
**✅ ローカル保存:**
- Hive DB: アプリ専用ディレクトリ
- 写真: カメラロール(ユーザー端末)
**✅ 外部送信(一時的、保存されない):**
- Gemini API: OCRテキストのみ画像は送らない
**❌ 外部送信なし:**
- 個人情報の無断クラウド保存
- サードパーティへのデータ販売
### 11.2 将来のFirebase連携
- ユーザーが明示的に「同期」を選択した場合のみ
- 画像はFirebase Storageへ
- テキストデータはFirestoreへ
---
## 🎯 12. 量産対応(ワイン・ビールアプリへの展開)
### 12.1 拡張方法
`metadata.app_type` を変更するだけで転用可能:
- `sake` → 日本酒
- `wine` → ワイン
- `beer` → クラフトビール
- `whisky` → ウイスキー
### 12.2 共通化できる機能
- 撮影・OCR・AI解析フロー
- ポイントシステム
- PDF出力・QR循環
- マイページ・設定
### 12.3 差分化が必要な箇所
- JSONの `hidden_specs` フィールド
- ワイン: ぶどう品種、産地、ヴィンテージ
- ビール: ホップ、モルト、IBU値
- キャッチコピーのトーン
---
## 📝 13. AIエージェントへの指示
### 13.1 開発時の鉄則
1. **この仕様書を最優先のルール(バイブル)として参照せよ**
2. **display_data と hidden_specs を明確に分離せよ**
3. **カードUIはシンプルに保ち、display_dataのみ使用せよ**
4. **挫折防止のため、段階的に実装せよPhase順守**
5. **モックアップがある場合は1px単位で厳密再現せよ**
### 13.2 コード生成時の注意
- null安全性を徹底
- エラーハンドリングを統一
- デバッグログを適切に配置
- コメントは日本語で簡潔に
### 13.3 質問・提案時のルール
- 仕様書と矛盾する提案をする場合は理由を明記
- 新機能追加時はPhaseを提案
- データ構造変更時はJSON例を提示
---
## 📞 14. サポート・連絡先
**プロジェクトオーナー**: posimai
**開発支援AI**: Claude (Anthropic), Gemini (Google AI), ChatGPT (OpenAI)
**バージョン管理**: Git (GitHub)
**CI/CD**: Vercel (Web), Firebase Hosting
---
## 🎉 15. 最後に
この仕様書は「挫折しない日本酒アプリ開発」のために、複数のAIの知恵を結集して作成されました。
**重要な心構え:**
- シンプルから始める
- データは豊富に持つが、表示は最小限に
- ユーザーが「楽しい」と感じる体験を最優先に
**Let's build the best sake app together! 🍶✨**
---
**End of CommonSpecification.md v1.0**

View File

@ -1,113 +0,0 @@
CommonSpecification.md (v1.0)
1. プロジェクト概要
アプリ名: ぽんるーむPon-Room
コンセプト: 日本酒の「記録・解析・循環」を支えるAIアプリ。
ターゲット: - B2C: 一般ユーザー(自分の日本酒体験を記録・診断・共有)
B2B: 飲食店自店の日本酒メニューをPDF/QR付きで作成・印刷
2. 技術スタック
Frontend: Flutter (iOS/Android/Web)
Backend/DB: Cloud Firestore, Firebase Auth
AI/ML: - Local OCR: Google ML Kit (テキスト抽出)
LLM Analysis: Gemini API (2.5 Flash / 3 Flash)
Library: - gal: カメラロール保存用
pdf, printing: PDF生成・印刷用
qr_flutter: QRコード生成用
3. 共通データ構造 (JSON Schema)
すべてのAI解析およびデータ保存はこの形式に従うこと。
JSON
{
"display_data": {
"name": "銘柄名",
"catch_phrase": "AI生成のキャッチコピー",
"image_path": "local/path/to/image.jpg",
"rating": 4.5
},
"hidden_specs": {
"brewery": "蔵元名",
"prefecture": "都道府県",
"type": "特定名称(純米大吟醸等)",
"alcohol_content": 16.0,
"polishing_ratio": 23,
"rice_variety": "使用米",
"sake_meter_value": 0.0,
"qr_code_url": "https://pon-room.app/sake/12345"
},
"badges": {
"is_recommended": false,
"is_seasonal": false,
"season_tag": "春限定"
},
"gamification": {
"pon_points": 10,
"sake_mbti_type": "フルーティー・モダン型"
},
"user_data": {
"is_favorite": false,
"memo": "テイスティングメモ",
"created_at": "ISO8601形式"
},
"metadata": {
"app_type": "sake",
"version": "1.0"
}
}
4. 機能別ガイドライン
4.1 撮影・解析フロー (Hybrid Analysis)
撮影: 写真撮影後、即座に「カメラロール端末のギャラリー」へ保存するgalパッケージ使用
OCR: Google ML Kitで画像から生テキストを抽出。
AI解析: 抽出テキストのみをGeminiへ送信し、上記JSON形式で構造化データを取得。
注意: 画像を直接AIに送るのではなく、テキストを介することでトークン節約とエラー回避を行う。
4.2 UI構成 (5タブ・切り替え制)
タブ1 (リスト): カード形式。display_dataのみを表示し、極力シンプルに保つ。
タブ2 (スキャン/作成): - Consumerモード: QRスキャン他店メニューの読み取り・ポイント獲得
Businessモード: PDFメニュー作成QRコード自動埋め込み
タブ3 (AI/診断): AIソムリエ、酒蔵マップ将来拡張
タブ4 (マップ): 酒蔵巡りマップ(未実装)。
タブ5 (マイページ): - 酒向(しゅこう)カード: MBTI風の自己診断結果、バッジ、獲得ポイントを表示。
設定: 右上の「歯車アイコン」内にモード切り替え、使い方ガイド、システム設定を集約。
4.3 オンボーディング (Onboarding)
初回起動時に4枚のステップガイドを表示。
各画面右上の「?」アイコンでガイドを再表示。
Businessモード切り替え時は専用の3ステップガイドを追加表示。
4.4 B2B/B2C 循環ロジック
B2B: PDF出力時に各銘柄の qr_code_url をQRコードとして埋め込む。
B2C: そのQRをスキャンすると、詳細情報が表示され「ポンポイント」が貯まる仕組みを想定。
5. 開発優先順位 (Roadmap)
Phase 1: CommonSpecification.md に基づくデータモデルの再定義。
Phase 2: カメラロール保存機能の実装(データ消失リスク回避)。
Phase 3: PDF + printing 実装Antigravity評価用のB2B機能
Phase 4: OCR + Gemini API連携の最適化。
Phase 5: マイページ(酒向カード)およびゲーミフィケーション実装。

BIN
all_models.json Normal file

Binary file not shown.

View File

@ -54,5 +54,13 @@
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
<intent>
<action android:name="android.intent.action.PICK"/>
<data android:mimeType="image/*"/>
</intent>
<intent>
<action android:name="android.intent.action.GET_CONTENT"/>
<data android:mimeType="image/*"/>
</intent>
</queries>
</manifest>

View File

@ -6,42 +6,42 @@ class JapanMapData {
// Designed to show Hokkaido size, Honshu curve, and close Kyushu/Shikoku
static final List<List<int>> gridLayout = [
// Hokkaido (Top) - Huge & Diamond shape approx
// Hokkaido (Top) - Refined Diamond Shape
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0], // Tip
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 0, 0, 0, 0, 0], // Widen
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 0, 0, 0, 0], // Widest top
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0], // Mid
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0], // Narrowing
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0], // Oshinima Peninsula
// Shifted Left by 3
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0], // Tip
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0], // Widen
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0], // Widest top
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0], // Mid
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0], // Narrowing
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], // Oshinima Peninsula
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], // Tsugaru Strait
// Tohoku (The vertical stick)
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2, 2, 2, 0, 0, 0, 0, 0, 0, 0], // Aomori
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 5, 5, 3, 3, 0, 0, 0, 0, 0, 0, 0], // Akita, Iwate
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 5, 3, 3, 3, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 6, 6, 4, 4, 0, 0, 0, 0, 0, 0, 0], // Yamagata, Miyagi
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 15, 15, 7, 7, 7, 0, 0, 0, 0, 0, 0, 0], // Niigata, Fukushima
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2, 2, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], // Aomori
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 5, 5, 3, 3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], // Akita, Iwate
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 5, 3, 3, 3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 6, 6, 4, 4, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], // Yamagata, Miyagi
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 15, 15, 7, 7, 7, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], // Niigata, Fukushima
// Kanto & Chubu (The bulging turn)
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 15, 15, 10, 9, 9, 8, 8, 0, 0, 0, 0, 0, 0], // Niigata, Gunma, Tochigi, Ibaraki
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 17, 16, 20, 20, 11, 11, 12, 12, 0, 0, 0, 0, 0, 0], // Ishikawa, Toyama, Nagano, Saitama, Chiba
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 18, 21, 20, 19, 13, 13, 12, 0, 0, 0, 0, 0, 0, 0], // Fukui, Gifu, Nagano, Yamanashi, Tokyo, Chiba
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 18, 25, 21, 23, 22, 14, 14, 0, 0, 0, 0, 0, 0, 0], // Fukui, Shiga, Gifu, Aichi, Shizuoka, Kanagawa
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 15, 15, 10, 9, 9, 8, 8, 0, 0, 0, 0, 0, 0, 0, 0, 0], // Niigata, Gunma, Tochigi, Ibaraki
[0, 0, 0, 0, 0, 0, 0, 0, 17, 16, 20, 20, 11, 11, 12, 12, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], // Ishikawa, Toyama, Nagano, Saitama, Chiba
[0, 0, 0, 0, 0, 0, 0, 0, 18, 21, 20, 19, 13, 13, 12, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], // Fukui, Gifu, Nagano, Yamanashi, Tokyo, Chiba
[0, 0, 0, 0, 0, 0, 0, 0, 18, 25, 21, 23, 22, 14, 14, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], // Fukui, Shiga, Gifu, Aichi, Shizuoka, Kanagawa
// Kansai & Chugoku (Stretching West)
[0, 0, 0, 0, 0, 0, 0, 32, 31, 28, 26, 25, 24, 23, 22, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], // Shimane, Tottori, Hyogo, Kyoto, Shiga, Mie, Aichi, Shizuoka
[0, 0, 0, 0, 0, 35, 34, 33, 28, 27, 29, 24, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], // Yamaguchi, Hiroshima, Okayama, Hyogo, Osaka, Nara, Mie
[0, 0, 0, 0, 32, 31, 28, 26, 25, 24, 23, 22, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], // Shimane, Tottori, Hyogo, Kyoto, Shiga, Mie, Aichi, Shizuoka
[0, 0, 35, 34, 33, 28, 27, 29, 24, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], // Yamaguchi, Hiroshima, Okayama, Hyogo, Osaka, Nara, Mie
// Shikoku (Nestled under) & Wakayama
[0, 0, 0, 0, 0, 0, 0, 37, 36, 0, 30, 30, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], // Kagawa, Tokushima, Wakayama
[0, 0, 0, 0, 0, 0, 38, 38, 39, 39, 30, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], // Ehime, Kochi
[0, 0, 0, 0, 37, 36, 0, 30, 30, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], // Kagawa, Tokushima, Wakayama
[0, 0, 0, 38, 38, 39, 39, 30, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], // Ehime, Kochi
// Kyushu (Connecting West)
[0, 0, 0, 41, 40, 44, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], // Saga, Fukuoka, Oita
[0, 42, 42, 43, 44, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], // Nagasaki, Kumamoto, Oita
[0, 42, 43, 43, 45, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], // Nagasaki, Kumamoto, Miyazaki
[0, 0, 46, 46, 45, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], // Kagoshima, Miyazaki
[0, 0, 46, 46, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], // Kagoshima
[0, 41, 40, 44, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], // Saga, Fukuoka, Oita
[42, 42, 43, 44, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], // Nagasaki, Kumamoto, Oita
[42, 43, 43, 45, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], // Nagasaki, Kumamoto, Miyazaki
[0, 46, 46, 45, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], // Kagoshima, Miyazaki
[0, 46, 46, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], // Kagoshima
// Gap
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],

View File

@ -20,69 +20,68 @@ class PrefectureTileLayout {
static Map<String, TilePosition> get getLayout => finalLayout;
static const Map<String, TilePosition> finalLayout = {
// Hokkaido
// Hokkaido & Tohoku
'北海道': TilePosition(col: 11, row: 0, width: 2, height: 2),
// Tohoku
'青森': TilePosition(col: 11, row: 2),
'秋田': TilePosition(col: 10, row: 3), // Was 11 - no wait, Akita was 11.
'秋田': TilePosition(col: 10, row: 3),
'岩手': TilePosition(col: 11, row: 3),
'山形': TilePosition(col: 10, row: 4), // Was 11
'山形': TilePosition(col: 10, row: 4),
'宮城': TilePosition(col: 11, row: 4),
'福島': TilePosition(col: 11, row: 5),
// Kanto & Koshinetsu
'茨城': TilePosition(col: 12, row: 6),
'栃木': TilePosition(col: 11, row: 6),
'群馬': TilePosition(col: 10, row: 6), // Was 11? No wait.
'埼玉': TilePosition(col: 10, row: 7), // Was 11
'東京': TilePosition(col: 10, row: 8), // Was 11
'千葉': TilePosition(col: 11, row: 8),
'神奈川': TilePosition(col: 10, row: 9), // Was 11
'山梨': TilePosition(col: 10, row: 7),
'長野': TilePosition(col: 10, row: 6),
'新潟': TilePosition(col: 11, row: 5),
'群馬': TilePosition(col: 10, row: 6),
'埼玉': TilePosition(col: 10, row: 7),
'千葉': TilePosition(col: 11, row: 7), // Right of Saitama
'東京': TilePosition(col: 10, row: 8),
'神奈川': TilePosition(col: 10, row: 9),
'新潟': TilePosition(col: 10, row: 5), // Left of Fukushima (11,5) -> 10,5? Gunma is 10,6. OK.
'長野': TilePosition(col: 9, row: 6), // Left of Gunma
'山梨': TilePosition(col: 9, row: 7), // Left of Saitama
'静岡': TilePosition(col: 9, row: 8), // Left of Tokyo
// Hokuriku & Tokai
'富山': TilePosition(col: 10, row: 5),
'石川': TilePosition(col: 9, row: 5),
'福井': TilePosition(col: 9, row: 6),
'岐阜': TilePosition(col: 9, row: 7),
'愛知': TilePosition(col: 9, row: 8),
'静岡': TilePosition(col: 10, row: 8),
'三重': TilePosition(col: 8, row: 8),
'富山': TilePosition(col: 9, row: 5), // Left of Niigata
'石川': TilePosition(col: 8, row: 5), // Left of Toyama
'福井': TilePosition(col: 8, row: 6),
'岐阜': TilePosition(col: 8, row: 7),
'愛知': TilePosition(col: 8, row: 8),
'三重': TilePosition(col: 7, row: 8),
// Kinki
'滋賀': TilePosition(col: 8, row: 7),
'京都': TilePosition(col: 7, row: 7),
'大阪': TilePosition(col: 7, row: 8),
'兵庫': TilePosition(col: 6, row: 7),
'奈良': TilePosition(col: 8, row: 9),
'和歌山': TilePosition(col: 7, row: 9),
'滋賀': TilePosition(col: 7, row: 7),
'京都': TilePosition(col: 6, row: 7),
'大阪': TilePosition(col: 6, row: 8),
'兵庫': TilePosition(col: 5, row: 7), // Left of Kyoto
'奈良': TilePosition(col: 7, row: 9), // Below Shiga/Mie area? (7,9)
'和歌山': TilePosition(col: 6, row: 9), // Below Osaka
// Chugoku
'鳥取': TilePosition(col: 5, row: 7),
'岡山': TilePosition(col: 5, row: 8),
'島根': TilePosition(col: 4, row: 7),
'広島': TilePosition(col: 4, row: 8),
'山口': TilePosition(col: 3, row: 8),
'鳥取': TilePosition(col: 4, row: 7),
'岡山': TilePosition(col: 4, row: 8),
'島根': TilePosition(col: 3, row: 7),
'広島': TilePosition(col: 3, row: 8),
'山口': TilePosition(col: 2, row: 8),
// Shikoku
'香川': TilePosition(col: 6, row: 9),
'徳島': TilePosition(col: 6, row: 10),
'愛媛': TilePosition(col: 5, row: 9),
'高知': TilePosition(col: 5, row: 10),
'香川': TilePosition(col: 5, row: 9), // Below Hyogo/Are (5,7) -> Gap at 8. Correct.
'徳島': TilePosition(col: 5, row: 10),
'愛媛': TilePosition(col: 4, row: 9),
'高知': TilePosition(col: 4, row: 10),
// Kyushu
'福岡': TilePosition(col: 2, row: 8),
'大分': TilePosition(col: 2, row: 9),
'佐賀': TilePosition(col: 1, row: 8),
'長崎': TilePosition(col: 0, row: 8),
'熊本': TilePosition(col: 1, row: 9),
'宮崎': TilePosition(col: 2, row: 10),
'鹿児島': TilePosition(col: 1, row: 10),
// Kyushu (2 columns)
'福岡': TilePosition(col: 1, row: 8),
'大分': TilePosition(col: 1, row: 9),
'宮崎': TilePosition(col: 1, row: 10),
'佐賀': TilePosition(col: 0, row: 8),
'長崎': TilePosition(col: 0, row: 9),
'熊本': TilePosition(col: 0, row: 10),
'鹿児島': TilePosition(col: 0, row: 11),
// Okinawa
'沖縄': TilePosition(col: 0, row: 11),
'沖縄': TilePosition(col: 0, row: 12),
};
}

View File

@ -235,6 +235,14 @@ class SakeItem extends HiveObject {
double? markup,
Map<String, int>? priceVariants,
ItemType? itemType,
// New Specs
String? specificDesignation, // Maps to hiddenSpecs.type
double? alcoholContent,
int? polishingRatio,
double? sakeMeterValue,
String? riceVariety,
String? yeast,
String? manufacturingYearMonth,
}) {
// Ensure we have current data structures
final currentDisplay = displayData;
@ -258,6 +266,13 @@ class SakeItem extends HiveObject {
flavorTags: flavorTags,
sweetnessScore: sweetnessScore,
bodyScore: bodyScore,
type: specificDesignation,
alcoholContent: alcoholContent,
polishingRatio: polishingRatio,
sakeMeterValue: sakeMeterValue,
riceVariety: riceVariety,
yeast: yeast,
manufacturingYearMonth: manufacturingYearMonth,
),
userData: currentUser.copyWith(
isFavorite: isFavorite,

View File

@ -93,7 +93,7 @@ class PdfIncludePriceNotifier extends Notifier<bool> {
final pdfDensityProvider = NotifierProvider<PdfDensityNotifier, double>(PdfDensityNotifier.new);
class PdfDensityNotifier extends Notifier<double> {
@override
double build() => 1.2; // Default 1.2 "High Density"
double build() => 1.8; // Default 1.8 "High Density"
void set(double value) => state = value;
}

View File

@ -4,19 +4,23 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
class UiExperimentSettings {
final int gridColumns; // 2 or 3
final String fabAnimation; // 'rotate' or 'bounce'
final bool isMapColorful; // true: Region Colors, false: Posimai/Grey
const UiExperimentSettings({
this.gridColumns = 2,
this.fabAnimation = 'rotate',
this.isMapColorful = false,
});
UiExperimentSettings copyWith({
int? gridColumns,
String? fabAnimation,
bool? isMapColorful,
}) {
return UiExperimentSettings(
gridColumns: gridColumns ?? this.gridColumns,
fabAnimation: fabAnimation ?? this.fabAnimation,
isMapColorful: isMapColorful ?? this.isMapColorful,
);
}
}
@ -37,4 +41,8 @@ class UiExperimentNotifier extends Notifier<UiExperimentSettings> {
void setFabAnimation(String animation) {
state = state.copyWith(fabAnimation: animation);
}
void setMapColorful(bool isColorful) {
state = state.copyWith(isMapColorful: isColorful);
}
}

View File

@ -14,7 +14,7 @@ import '../widgets/analyzing_dialog.dart';
import '../models/sake_item.dart';
import '../theme/app_theme.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:image_picker/image_picker.dart'; // Gallery Import
import 'package:image_picker/image_picker.dart'; // Gallery & Camera
import '../models/user_profile.dart';
import '../providers/theme_provider.dart'; // userProfileProvider
@ -235,10 +235,36 @@ class _CameraScreenState extends ConsumerState<CameraScreen> with SingleTickerPr
Future<void> _pickFromGallery() async {
final picker = ImagePicker();
final XFile? image = await picker.pickImage(source: ImageSource.gallery);
// Use standard image_picker (Updated to 1.1.2 for Android 13+)
final List<XFile> images = await picker.pickMultiImage();
if (image != null && mounted) {
await _handleCapturedImage(image.path, fromGallery: true);
if (images.isNotEmpty && mounted) {
// IF RETURN PATH Mode (Only supports one)
if (widget.mode == CameraMode.returnPath) {
Navigator.of(context).pop(images.first.path);
return;
}
setState(() {
for (var img in images) {
_capturedImages.add(img.path);
}
});
// Batch handle - Notification only
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('${images.length}枚の画像を読み込みました。\n右下のボタンから解析を開始してください。'),
duration: const Duration(seconds: 3),
action: SnackBarAction(
label: '解析する',
onPressed: _analyzeImages,
textColor: Colors.yellow,
),
),
);
}
}
}
@ -657,7 +683,17 @@ class _CameraScreenState extends ConsumerState<CameraScreen> with SingleTickerPr
),
),
// Right Spacer (Balance)
// Right Spacer -> Analyze Button if images exist
if (_capturedImages.isNotEmpty)
IconButton(
icon: Badge(
label: Text('${_capturedImages.length}'),
child: const Icon(LucideIcons.playCircle, color: Colors.greenAccent, size: 40),
),
onPressed: _analyzeImages,
tooltip: '解析を開始',
)
else
const SizedBox(width: 48),
],
),

View File

@ -42,6 +42,15 @@ class DevMenuScreen extends ConsumerWidget {
onChanged: (val) => ref.read(uiExperimentProvider.notifier)
.setFabAnimation(val ? 'bounce' : 'rotate'),
),
const Divider(),
SwitchListTile(
secondary: const Text('🎨', style: TextStyle(fontSize: 24)),
title: const Text('地図お試しカラー'),
subtitle: Text('地方ごとに色分け (現在: ${experiment.isMapColorful ? 'ON' : 'OFF'})'),
value: experiment.isMapColorful,
onChanged: (val) => ref.read(uiExperimentProvider.notifier)
.setMapColorful(val),
),
],
),
),

View File

@ -65,6 +65,8 @@ class HomeScreen extends ConsumerWidget {
final userProfile = ref.watch(userProfileProvider);
final isBusinessMode = userProfile.isBusinessMode;
final hasItems = ref.watch(rawSakeListItemsProvider).asData?.value.isNotEmpty ?? false;
return Scaffold(
appBar: AppBar(
title: isMenuMode
@ -137,7 +139,7 @@ class HomeScreen extends ConsumerWidget {
body: SafeArea(
child: Column(
children: [
if (!isMenuMode)
if (!isMenuMode && hasItems)
SakeFilterChips(
mode: isBusinessMode ? FilterChipMode.business : FilterChipMode.personal
),
@ -182,7 +184,7 @@ class HomeScreen extends ConsumerWidget {
),
);
} else if (isListActuallyEmpty) {
return const HomeEmptyState();
return HomeEmptyState(isMenuMode: isMenuMode);
} else {
return const SakeNoMatchState();
}

View File

@ -147,7 +147,7 @@ class MenuCreationScreen extends ConsumerWidget {
),
);
} else if (isListActuallyEmpty) {
return const HomeEmptyState();
return const HomeEmptyState(isMenuMode: true);
} else {
return const SakeNoMatchState();
}

View File

@ -260,8 +260,8 @@ class _MenuSettingsScreenState extends ConsumerState<MenuSettingsScreen> {
const Divider(height: 1),
// QR Toggle
SwitchListTile(
title: const Text('QRコード (お持ち帰り用)'),
subtitle: const Text('アプリで読み取れる情報を埋め込みます', style: TextStyle(fontSize: 10, color: Colors.grey)),
title: const Text('QRコード(銘柄情報表示)'),
subtitle: const Text('ぽんるーむアプリでスキャンすると銘柄情報を表示', style: TextStyle(fontSize: 10, color: Colors.grey)),
value: includeQr,
onChanged: (val) => setState(() {
includeQr = val;

View File

@ -66,7 +66,10 @@ class _BreweryMapScreenState extends ConsumerState<BreweryMapScreen> {
child: Row(
mainAxisAlignment: MainAxisAlignment.start, // Left align
children: [
_buildLegendDot(Colors.grey[300]!, '未開拓'),
_buildLegendDot(
Theme.of(context).brightness == Brightness.dark ? Colors.grey[800]! : Colors.grey[300]!,
'未開拓'
),
const SizedBox(width: 12),
_buildLegendDot(AppTheme.posimaiBlue, '制覇済'),
],

View File

@ -80,6 +80,7 @@ class _SakeDetailScreenState extends ConsumerState<SakeDetailScreen> {
expandedHeight: 400.0,
floating: false,
pinned: true,
backgroundColor: Theme.of(context).primaryColor,
iconTheme: const IconThemeData(color: Colors.white),
actions: [
MunyunLikeButton(
@ -416,13 +417,18 @@ class _SakeDetailScreenState extends ConsumerState<SakeDetailScreen> {
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Column(
children: [
_buildSpecRow('特定名称', _sake.hiddenSpecs.type ?? '-'),
_buildSpecRow('甘辛度', _sake.hiddenSpecs.sweetnessScore?.toStringAsFixed(1) ?? '-'),
_buildSpecRow('濃淡度', _sake.hiddenSpecs.bodyScore?.toStringAsFixed(1) ?? '-'),
const SizedBox(height: 8),
Text(
'※ 今後のアップデートで精米歩合、アルコール度数などの詳細スペックを追加予定',
style: TextStyle(color: Colors.grey[600], fontSize: 12),
),
const Divider(height: 16),
const SizedBox(height: 8),
_buildSpecRow('精米歩合', _sake.hiddenSpecs.polishingRatio != null ? '${_sake.hiddenSpecs.polishingRatio}%' : '-'),
_buildSpecRow('アルコール分', _sake.hiddenSpecs.alcoholContent != null ? '${_sake.hiddenSpecs.alcoholContent}' : '-'),
_buildSpecRow('日本酒度', _sake.hiddenSpecs.sakeMeterValue != null ? '${_sake.hiddenSpecs.sakeMeterValue! > 0 ? '+' : ''}${_sake.hiddenSpecs.sakeMeterValue}' : '-'),
_buildSpecRow('酒米', _sake.hiddenSpecs.riceVariety ?? '-'),
_buildSpecRow('酵母', _sake.hiddenSpecs.yeast ?? '-'),
_buildSpecRow('製造年月', _sake.hiddenSpecs.manufacturingYearMonth ?? '-'),
],
),
),
@ -648,6 +654,15 @@ class _SakeDetailScreenState extends ConsumerState<SakeDetailScreen> {
confidenceScore: result.confidenceScore,
flavorTags: result.flavorTags.isNotEmpty ? result.flavorTags : _sake.hiddenSpecs.flavorTags,
tasteStats: result.tasteStats.isNotEmpty ? result.tasteStats : _sake.hiddenSpecs.tasteStats,
// New Fields
specificDesignation: result.type ?? _sake.hiddenSpecs.type,
alcoholContent: result.alcoholContent ?? _sake.hiddenSpecs.alcoholContent,
polishingRatio: result.polishingRatio ?? _sake.hiddenSpecs.polishingRatio,
sakeMeterValue: result.sakeMeterValue ?? _sake.hiddenSpecs.sakeMeterValue,
riceVariety: result.riceVariety ?? _sake.hiddenSpecs.riceVariety,
yeast: result.yeast ?? _sake.hiddenSpecs.yeast,
manufacturingYearMonth: result.manufacturingYearMonth ?? _sake.hiddenSpecs.manufacturingYearMonth,
itemType: ItemType.sake,
);
final box = Hive.box<SakeItem>('sake_items');

View File

@ -26,14 +26,14 @@ class _ShopSettingsScreenState extends ConsumerState<ShopSettingsScreen> {
return Scaffold(
appBar: AppBar(
title: const Text('店舗設定'),
title: const Text('店舗ページ'),
centerTitle: true,
),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
// Business Config Section
_buildSectionHeader(context, 'ビジネス設定', LucideIcons.briefcase),
_buildSectionHeader(context, '価格設定', LucideIcons.briefcase),
Card(
color: isDark ? const Color(0xFF1E1E1E) : null,
child: ListTile(

View File

@ -96,7 +96,7 @@ class _SoulScreenState extends ConsumerState<SoulScreen> {
// other Settings
const OtherSettingsSection(
title: 'データ・その他',
title: 'その他',
),
const SizedBox(height: 24),

View File

@ -0,0 +1,3 @@
class Secrets {
static const String geminiApiKey = 'AIzaSyD9FubZhzrDKFbFtGqBQdfOuP1QTZ5vJf4';
}

3
lib/secrets_bk.dart Normal file
View File

@ -0,0 +1,3 @@
class Secrets {
static const String geminiApiKey = 'AIzaSyD9FubZhzrDKFbFtGqBQdfOuP1QTZ5vJf4';
}

View File

@ -0,0 +1,55 @@
import 'dart:convert';
import 'package:crypto/crypto.dart';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:flutter/foundation.dart';
/// ID取得サービス
/// 使
class DeviceService {
static final DeviceInfoPlugin _deviceInfo = DeviceInfoPlugin();
static String? _cachedDeviceId;
/// IDを取得SHA256ハッシュ化
static Future<String> getDeviceId() async {
//
if (_cachedDeviceId != null) {
return _cachedDeviceId!;
}
try {
String deviceIdentifier;
if (defaultTargetPlatform == TargetPlatform.android) {
final androidInfo = await _deviceInfo.androidInfo;
// Android IDを使用IDを維持
deviceIdentifier = androidInfo.id;
} else if (defaultTargetPlatform == TargetPlatform.iOS) {
final iosInfo = await _deviceInfo.iosInfo;
// identifierForVendor
deviceIdentifier = iosInfo.identifierForVendor ?? 'unknown-ios';
} else {
//
deviceIdentifier = 'unknown-platform';
}
// SHA256ハッシュ化64
final bytes = utf8.encode(deviceIdentifier);
final digest = sha256.convert(bytes);
_cachedDeviceId = digest.toString();
debugPrint('Device ID (hashed): ${_cachedDeviceId!.substring(0, 8)}...');
return _cachedDeviceId!;
} catch (e) {
debugPrint('Error getting device ID: $e');
// IDを生成IDを使用
_cachedDeviceId = sha256.convert(utf8.encode('fallback-${DateTime.now().millisecondsSinceEpoch}')).toString();
return _cachedDeviceId!;
}
}
///
static void reset() {
_cachedDeviceId = null;
}
}

View File

@ -1,160 +1,37 @@
import 'dart:io';
import 'dart:convert';
import 'package:google_generative_ai/google_generative_ai.dart';
import 'package:http/http.dart' as http;
import 'package:flutter/foundation.dart';
import '../secrets.dart';
import 'device_service.dart';
// import '../secrets.dart'; // No longer needed
class GeminiService {
late final GenerativeModel _model;
// AI Proxy Server Configuration
static const String _proxyUrl = 'http://192.168.31.89:8080/analyze';
// : API呼び出し時刻を記録
// ? Proxy側で管理されているが
static DateTime? _lastApiCallTime;
static const Duration _minApiInterval = Duration(seconds: 5); // 5
static const Duration _minApiInterval = Duration(seconds: 2);
// : gemini-2.5-flash () gemini-2.5-pro ()
// Google One Pro会員でもAPI料金は別途発生します
// : Google AI Studio Billing Pay-as-you-go設定後
// 'gemini-2.5-pro'
// Lite is 503 Overloaded. Trying Standard Flash.
static const _modelName = 'gemini-2.5-flash'; // Pro版: 'gemini-2.5-pro'
GeminiService();
GeminiService() {
_model = GenerativeModel(
model: _modelName,
apiKey: Secrets.geminiApiKey,
// :
safetySettings: [
SafetySetting(HarmCategory.harassment, HarmBlockThreshold.none),
SafetySetting(HarmCategory.hateSpeech, HarmBlockThreshold.none),
],
///
Future<SakeAnalysisResult> analyzeSakeLabel(List<String> imagePaths) async {
// 使customPromptはnullを送信
return _callProxyApi(
imagePaths: imagePaths,
customPrompt: null,
);
}
Future<SakeAnalysisResult> analyzeSakeLabel(List<String> imagePaths) async {
try {
//
if (_lastApiCallTime != null) {
final elapsed = DateTime.now().difference(_lastApiCallTime!);
if (elapsed < _minApiInterval) {
await Future.delayed(_minApiInterval - elapsed);
}
}
if (imagePaths.isEmpty) throw Exception("画像が選択されていません");
debugPrint('Analyzing ${imagePaths.length} images...');
const prompt = '''
JSON形式で返してください
{
"name": "銘柄名(例:獺祭 純米大吟醸)",
"brand": "蔵元名(例:旭酒造)",
"prefecture": "都道府県名(例:山口県)",
"type": "種類(例:純米大吟醸)",
"description": "この日本酒の特徴を100文字程度で説明裏ラベルの情報があれば活用してください",
"catchCopy": "この日本酒を一言で表現する詩的なキャッチコピー20文字以内",
"confidenceScore": 0100,
"flavorTags": ["フルーティ", "辛口"],
"tasteStats": {
"aroma": 3,
"sweetness": 3,
"acidity": 3,
"bitterness": 3,
"body": 3
}
}
**tasteStatsの説明 (1-5)**:
- aroma:
- sweetness:
- acidity:
- bitterness: /
- body:
null
JSONのみを返し
''';
final parts = <Part>[TextPart(prompt)];
for (final path in imagePaths) {
final bytes = await File(path).readAsBytes();
// Simple mime type assumption, Gemini is lenient
parts.add(DataPart('image/jpeg', bytes));
debugPrint('Loaded image: ${(bytes.length / 1024).toStringAsFixed(1)}KB');
}
final content = [Content.multi(parts)];
// API呼び出し
_lastApiCallTime = DateTime.now(); //
final response = await _model.generateContent(content);
final text = response.text ?? '';
// 使()
if (response.usageMetadata != null) {
debugPrint('Token usage - Prompt: ${response.usageMetadata!.promptTokenCount}, '
'Response: ${response.usageMetadata!.candidatesTokenCount}, '
'Total: ${response.usageMetadata!.totalTokenCount}');
}
// Parse JSON (remove markdown code blocks if present)
final jsonText = text.trim()
.replaceAll('```json', '')
.replaceAll('```', '')
.trim();
final Map<String, dynamic> json = jsonDecode(jsonText);
return SakeAnalysisResult.fromJson(json);
} catch (e) {
debugPrint('Gemini API error: $e');
//
final errorString = e.toString().toLowerCase();
if (errorString.contains('429') || errorString.contains('quota') || errorString.contains('resource_exhausted')) {
//
if (errorString.contains('quota')) {
throw Exception('AI使用制限に達しました。\n'
'無料版は1分間に15回までの制限があります。\n'
'1〜2分後に再度お試しください。');
} else {
throw Exception('APIレート制限エラー(429)。\n'
'画像解析の頻度が高すぎます。\n'
'数分後に再度お試しください。');
}
}
//
rethrow;
}
}
/// OCRテキストと画像のハイブリッド解析
Future<SakeAnalysisResult> analyzeSakeHybrid(String extractedText, List<String> imagePaths) async {
try {
//
if (_lastApiCallTime != null) {
final elapsed = DateTime.now().difference(_lastApiCallTime!);
if (elapsed < _minApiInterval) {
await Future.delayed(_minApiInterval - elapsed);
}
}
if (extractedText.isEmpty || imagePaths.isEmpty) throw Exception("テキストまたは画像がありません");
debugPrint('Analyzing hybrid (Text: ${extractedText.length} chars, Images: ${imagePaths.length})...');
final prompt = '''
OCR抽出テキストと添付の日本酒ラベル画像を組み合わせて分析してください
OCRの性質上Data 29Daia 2Y
****
OCRの性質上
1. ****: :"KIMOTO":"KIMOTO 35"
2. ****: **(90)**
:
"""
@ -162,173 +39,142 @@ $extractedText
"""
JSON形式で返してください
{
"name": "銘柄名(例:獺祭 純米大吟醸)",
"brand": "蔵元名(例:旭酒造)",
"prefecture": "都道府県名(例:山口県)",
"type": "種類(例:純米大吟醸)",
"description": "この日本酒の特徴を100文字程度で説明裏ラベルの情報があれば活用してください",
"catchCopy": "この日本酒を一言で表現する詩的なキャッチコピー20文字以内",
"confidenceScore": 0100,
"flavorTags": ["フルーティ", "辛口"],
"tasteStats": {
"aroma": 3,
"sweetness": 3,
"acidity": 3,
"bitterness": 3,
"body": 3
"name": "銘柄名",
"brand": "蔵元名",
"prefecture": "都道府県名",
"type": "特定名称",
"description": "特徴(100文字)",
"catchCopy": "キャッチコピー(20文字)",
"confidenceScore": 0-100,
"flavorTags": ["タグ"],
"tasteStats": {"aroma":3,"sweetness":3,"acidity":3,"bitterness":3,"body":3},
"alcoholContent": 15.5,
"polishingRatio": 50,
"sakeMeterValue": 3.0,
"riceVariety": "山田錦",
"yeast": "きょうかい9号",
"manufacturingYearMonth": "2023.10"
}
}
**tasteStatsの説明 (1-5)**:
- aroma:
- sweetness:
- acidity:
- bitterness: /
- body:
JSONのみを返し
''';
final parts = <Part>[TextPart(prompt)];
// 1
//
for (final path in imagePaths) {
final bytes = await File(path).readAsBytes();
parts.add(DataPart('image/jpeg', bytes));
}
final content = [Content.multi(parts)];
// API呼び出し
_lastApiCallTime = DateTime.now();
final response = await _model.generateContent(content);
final text = response.text ?? '';
// 使
if (response.usageMetadata != null) {
debugPrint('Token usage (Hybrid) - Prompt: ${response.usageMetadata!.promptTokenCount}, '
'Response: ${response.usageMetadata!.candidatesTokenCount}, '
'Total: ${response.usageMetadata!.totalTokenCount}');
}
final jsonText = text.trim()
.replaceAll('```json', '')
.replaceAll('```', '')
.trim();
final Map<String, dynamic> json = jsonDecode(jsonText);
return SakeAnalysisResult.fromJson(json);
} catch (e) {
_handleError(e);
rethrow;
}
return _callProxyApi(imagePaths: imagePaths, customPrompt: prompt);
}
/// ()
Future<SakeAnalysisResult> analyzeSakeText(String extractedText) async {
final prompt = '''
OCRで抽出された日本酒ラベルのテキスト情報を分析してください
:
"""
$extractedText
"""
JSON形式で返してください
{
"name": "銘柄名",
"brand": "蔵元名",
"prefecture": "都道府県名",
"type": "特定名称",
"description": "特徴(100文字)",
"catchCopy": "キャッチコピー(20文字)",
"confidenceScore": 0-100,
"flavorTags": ["タグ"],
"tasteStats": {"aroma":3,"sweetness":3,"acidity":3,"bitterness":3,"body":3},
"alcoholContent": 15.5,
"polishingRatio": 50,
"sakeMeterValue": 3.0,
"riceVariety": "山田錦",
"yeast": "きょうかい9号",
"manufacturingYearMonth": "2023.10"
}
''';
return _callProxyApi(imagePaths: [], customPrompt: prompt);
}
/// : ProxyへのAPIコール
Future<SakeAnalysisResult> _callProxyApi({
required List<String> imagePaths,
String? customPrompt,
}) async {
try {
//
// 1. ()
if (_lastApiCallTime != null) {
final elapsed = DateTime.now().difference(_lastApiCallTime!);
if (elapsed < _minApiInterval) {
await Future.delayed(_minApiInterval - elapsed);
}
}
if (extractedText.isEmpty) throw Exception("テキストが抽出できませんでした");
debugPrint('Analyzing text (${extractedText.length} chars)...');
final prompt = '''
OCRで抽出された日本酒ラベルのテキスト情報を分析してください
OCRの性質上****
1. ****: : Kimoto **(80)**
2. **/**:
3. ****:
:
"""
$extractedText
"""
JSON形式で返してください
{
"name": "銘柄名(例:獺祭 純米大吟醸)",
"brand": "蔵元名(例:旭酒造)",
"prefecture": "都道府県名(例:山口県)",
"type": "種類(例:純米大吟醸)",
"description": "この日本酒の特徴を100文字程度で説明裏ラベルの情報があれば活用してください",
"catchCopy": "この日本酒を一言で表現する詩的なキャッチコピー20文字以内",
"confidenceScore": 0100,
"flavorTags": ["フルーティ", "辛口"],
"tasteStats": {
"aroma": 3,
"sweetness": 3,
"acidity": 3,
"bitterness": 3,
"body": 3
}
}
**tasteStatsの説明 (1-5)**:
- aroma:
- sweetness:
- acidity:
- bitterness: /
- body:
JSONのみを返し
''';
final content = [Content.text(prompt)];
_lastApiCallTime = DateTime.now();
final response = await _model.generateContent(content);
final text = response.text ?? '';
if (response.usageMetadata != null) {
debugPrint('Token usage (Text) - Prompt: ${response.usageMetadata!.promptTokenCount}, '
'Response: ${response.usageMetadata!.candidatesTokenCount}, '
'Total: ${response.usageMetadata!.totalTokenCount}');
// 2. Base64変換
List<String> base64Images = [];
for (final path in imagePaths) {
final bytes = await File(path).readAsBytes();
final base64String = base64Encode(bytes);
base64Images.add(base64String);
debugPrint('Encoded image: ${(bytes.length / 1024).toStringAsFixed(1)}KB');
}
final jsonText = text.trim()
.replaceAll('```json', '')
.replaceAll('```', '')
.trim();
// 3. ID取得
final deviceId = await DeviceService.getDeviceId();
debugPrint('Device ID: $deviceId');
final Map<String, dynamic> json = jsonDecode(jsonText);
// 4.
final requestBody = jsonEncode({
"device_id": deviceId,
"images": base64Images,
"prompt": customPrompt,
});
return SakeAnalysisResult.fromJson(json);
debugPrint('Calling Proxy: $_proxyUrl');
// 5.
final response = await http.post(
Uri.parse(_proxyUrl),
headers: {"Content-Type": "application/json"},
body: requestBody,
).timeout(const Duration(seconds: 45)); //
// 6.
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']}');
}
return SakeAnalysisResult.fromJson(data);
} else {
// Proxy側での論理エラー ()
throw Exception(jsonResponse['error'] ?? '不明なエラーが発生しました');
}
} else {
// HTTPエラー
debugPrint('Proxy Error: ${response.statusCode} ${response.body}');
throw Exception('サーバーエラー (${response.statusCode}): ${response.body}');
}
} catch (e) {
_handleError(e);
debugPrint('Proxy Call Failed: $e');
//
final errorMsg = e.toString().toLowerCase();
if (errorMsg.contains('limit') || errorMsg.contains('上限')) {
throw Exception('本日のAI解析リクエスト上限に達しました。\n明日またお試しください。');
}
rethrow;
}
}
void _handleError(Object e) {
debugPrint('Gemini API Error: $e');
final errorString = e.toString().toLowerCase();
if (errorString.contains('429') || errorString.contains('quota') || errorString.contains('resource_exhausted')) {
if (errorString.contains('quota')) {
throw Exception('AI使用制限に達しました。\n'
'無料版は1分間に15回までの制限があります。\n'
'1〜2分後に再度お試しください。');
} else {
throw Exception('APIレート制限エラー(429)。\n'
'画像解析の頻度が高すぎます。\n'
'数分後に再度お試しください。');
}
}
}
}
// Analysis Result Model
@ -343,6 +189,14 @@ class SakeAnalysisResult {
final List<String> flavorTags;
final Map<String, int> tasteStats;
// New Fields
final double? alcoholContent;
final int? polishingRatio;
final double? sakeMeterValue;
final String? riceVariety;
final String? yeast;
final String? manufacturingYearMonth;
SakeAnalysisResult({
this.name,
this.brand,
@ -353,6 +207,12 @@ class SakeAnalysisResult {
this.confidenceScore,
this.flavorTags = const [],
this.tasteStats = const {},
this.alcoholContent,
this.polishingRatio,
this.sakeMeterValue,
this.riceVariety,
this.yeast,
this.manufacturingYearMonth,
});
factory SakeAnalysisResult.fromJson(Map<String, dynamic> json) {
@ -373,6 +233,12 @@ class SakeAnalysisResult {
confidenceScore: json['confidenceScore'] as int?,
flavorTags: (json['flavorTags'] as List<dynamic>?)?.map((e) => e.toString()).toList() ?? [],
tasteStats: stats,
alcoholContent: (json['alcoholContent'] as num?)?.toDouble(),
polishingRatio: (json['polishingRatio'] as num?)?.toInt(),
sakeMeterValue: (json['sakeMeterValue'] as num?)?.toDouble(),
riceVariety: json['riceVariety'] as String?,
yeast: json['yeast'] as String?,
manufacturingYearMonth: json['manufacturingYearMonth'] as String?,
);
}
}

View File

@ -2,7 +2,12 @@ import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
class HomeEmptyState extends StatelessWidget {
const HomeEmptyState({super.key});
final bool isMenuMode;
const HomeEmptyState({
super.key,
this.isMenuMode = false,
});
@override
Widget build(BuildContext context) {
@ -12,10 +17,12 @@ class HomeEmptyState extends StatelessWidget {
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(LucideIcons.wine, size: 100, color: isDark ? Colors.grey[600] : Theme.of(context).primaryColor.withValues(alpha: 0.5)),
isMenuMode
? Icon(LucideIcons.scrollText, size: 80, color: isDark ? Colors.grey[600] : Theme.of(context).primaryColor.withValues(alpha: 0.5))
: const Text('🍶', style: TextStyle(fontSize: 80)),
const SizedBox(height: 16),
Text(
'まだ日本酒を登録していません',
isMenuMode ? 'お品書きに載せる日本酒がありません' : '日本酒のラベルを撮ってみましょう',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
color: isDark ? Colors.grey[400] : Colors.grey[700],
fontWeight: FontWeight.bold
@ -23,7 +30,9 @@ class HomeEmptyState extends StatelessWidget {
),
const SizedBox(height: 8),
Text(
'カメラボタンから「瞬撮」してみましょう!\n長押しでギャラリーからも追加できます',
isMenuMode
? 'まずはホーム画面に戻って、\n日本酒を登録してください'
: '右下のカメラボタンから「瞬撮」できます!\n長押しでギャラリーから追加もできます',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: isDark ? Colors.grey[500] : Colors.grey[600],

View File

@ -1,87 +0,0 @@
import 'package:flutter/material.dart';
import '../../models/maps/japan_map_data.dart';
import '../../theme/app_theme.dart';
class PixelJapanMap extends StatelessWidget {
final Set<String> visitedPrefectures;
final Function(String prefecture)? onPrefectureTap;
const PixelJapanMap({
super.key,
required this.visitedPrefectures,
this.onPrefectureTap,
});
@override
Widget build(BuildContext context) {
// Determine grid dimensions
final rows = JapanMapData.gridLayout.length;
final cols = JapanMapData.gridLayout[0].length;
// Fixed base cell size for drawing - FittedBox will scale it to screen
// Increased to 32.0 for better touch targets (Hit Box), visual size will be smaller
const double touchSize = 32.0;
const double visualSize = 22.0; // Slightly larger for visibility
const double gap = 0.0; // Gap is now handled by padding inside cell
final totalWidth = cols * (touchSize + gap);
final totalHeight = rows * (touchSize + gap);
return SizedBox(
width: totalWidth,
height: totalHeight,
child: Column(
children: JapanMapData.gridLayout.map((row) {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: row.map((prefId) {
return _buildCell(context, prefId, touchSize, visualSize);
}).toList(),
);
}).toList(),
),
);
}
Widget _buildCell(BuildContext context, int prefId, double touchSize, double visualSize) {
if (prefId == 0) {
return SizedBox(width: touchSize, height: touchSize);
}
final prefName = JapanMapData.prefectureNames[prefId] ?? '';
final isVisited = visitedPrefectures.any((p) => p.startsWith(prefName.replaceAll(RegExp(r'[都道府県]'), '')));
Color color;
if (isVisited) {
color = AppTheme.posimaiBlue;
} else {
final regionId = JapanMapData.getRegionId(prefId);
color = (regionId % 2 == 0) ? Colors.grey[200]! : Colors.grey[300]!;
}
return GestureDetector(
behavior: HitTestBehavior.opaque, // Ensure touches on transparent padding are caught
onTap: () {
if (prefName.isNotEmpty && onPrefectureTap != null) {
onPrefectureTap!(prefName);
}
},
child: Container(
width: touchSize,
height: touchSize,
alignment: Alignment.center,
child: Tooltip(
message: prefName,
child: Container(
width: visualSize,
height: visualSize,
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(visualSize * 0.15),
),
),
),
),
);
}
}

View File

@ -1,8 +1,11 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../models/maps/prefecture_tile_layout.dart';
import '../../models/maps/japan_map_data.dart';
import '../../theme/app_theme.dart';
import '../../providers/ui_experiment_provider.dart';
class PrefectureTileMap extends StatelessWidget {
class PrefectureTileMap extends ConsumerWidget {
final Set<String> visitedPrefectures;
final Function(String) onPrefectureTap;
final double tileSize;
@ -17,7 +20,9 @@ class PrefectureTileMap extends StatelessWidget {
});
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final isMapColorful = ref.watch(uiExperimentProvider).isMapColorful;
// 1. Determine Grid Bounds
int maxCol = 0;
int maxRow = 0;
@ -44,20 +49,60 @@ class PrefectureTileMap extends StatelessWidget {
top: pos.row * (tileSize + gap),
width: pos.width * tileSize + (pos.width - 1) * gap,
height: pos.height * tileSize + (pos.height - 1) * gap,
child: _buildTile(context, prefName, isVisited),
child: _buildTile(context, prefName, isVisited, isMapColorful),
);
}).toList(),
),
);
}
Widget _buildTile(BuildContext context, String prefName, bool isVisited) {
static const Map<int, Color> regionColors = {
1: Color(0xFF6A7FF0), // Hokkaido: Blue
2: Color(0xFF58C2F1), // Tohoku: Cyan
3: Color(0xFF39D353), // Kanto: Green
4: Color(0xFF36D3AD), // Chubu: Emerald
5: Color(0xFFAEDA38), // Kinki: YellowGreen
6: Color(0xFFE6D33C), // Chugoku: Yellow
7: Color(0xFFF9A057), // Shikoku: Orange
8: Color(0xFFFF7B7B), // Kyushu: Pink
};
Widget _buildTile(BuildContext context, String prefName, bool isVisited, bool isColorful) {
final isDark = Theme.of(context).brightness == Brightness.dark;
// Color Logic
final baseColor = isVisited ? AppTheme.posimaiBlue : (isDark ? Colors.grey[800]! : Colors.grey[300]!);
final textColor = isVisited ? Colors.white : (isDark ? Colors.grey[400] : Colors.grey[700]);
final borderColor = isDark ? Colors.grey[700]! : Colors.white;
// Find Region ID for coloring
int prefId = 0;
JapanMapData.prefectureNames.forEach((k, v) {
if (v.contains(prefName)) prefId = k;
});
final regionId = JapanMapData.getRegionId(prefId);
final regionColor = regionColors[regionId] ?? Colors.grey;
Color baseColor;
Color textColor;
Color borderColor;
BoxBorder? border; // Use BoxBorder specifically
if (isColorful) {
// --- Colorful Mode ---
if (isVisited) {
baseColor = regionColor;
textColor = Colors.white;
borderColor = Colors.transparent; // No border needed for filled
border = null;
} else {
baseColor = isDark ? Colors.grey[900]! : Colors.grey[100]!;
textColor = isDark ? Colors.grey[600]! : Colors.grey[400]!;
borderColor = regionColor.withOpacity(0.6);
border = Border.all(color: borderColor, width: 2);
}
} else {
// --- Classic Mode ---
baseColor = isVisited ? AppTheme.posimaiBlue : (isDark ? Colors.grey[800]! : Colors.grey[300]!);
textColor = isVisited ? Colors.white : (isDark ? Colors.grey[400]! : Colors.grey[700]!);
borderColor = isDark ? Colors.grey[700]! : Colors.white;
border = Border.all(color: borderColor, width: 1); // Subtle inner border
}
return Material(
color: baseColor,
@ -69,11 +114,11 @@ class PrefectureTileMap extends StatelessWidget {
alignment: Alignment.center,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(6),
border: Border.all(color: borderColor, width: 1), // Subtle inner border
border: border,
),
child: Text(
prefName,
style: TextStyle(
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: textColor,
fontSize: 12, // Small but legible
fontWeight: FontWeight.bold,

View File

@ -47,7 +47,7 @@ class SakeRadarChart extends StatelessWidget {
borderData: FlBorderData(show: false),
radarBorderData: const BorderSide(color: Colors.transparent),
titlePositionPercentageOffset: 0.2,
titleTextStyle: TextStyle(
titleTextStyle: Theme.of(context).textTheme.labelSmall?.copyWith(
color: Theme.of(context).brightness == Brightness.dark
? Colors.orange[100] // Light orange for labels
: primaryColor,

View File

@ -223,7 +223,7 @@ enum _BackupState { idle, signingIn, signingOut, backingUp, restoring }
color: currentUser != null ? Colors.green : (isDark ? Colors.orange[300] : Theme.of(context).primaryColor),
),
title: Text(currentUser == null ? 'Googleアカウント連携' : currentUser.email),
subtitle: Text(currentUser == null ? 'Google Driveと連携してバックアップ' : 'Google Driveにバックアップを保存できます'),
subtitle: currentUser == null ? const Text('Google Driveにバックアップ') : null,
trailing: (_state == _BackupState.signingIn || _state == _BackupState.signingOut)
? const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2))
: ElevatedButton(

BIN
models.txt Normal file

Binary file not shown.

View File

@ -313,6 +313,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.3.6"
device_info_plus:
dependency: "direct main"
description:
name: device_info_plus
sha256: a7fd703482b391a87d60b6061d04dfdeab07826b96f9abd8f5ed98068acc0074
url: "https://pub.dev"
source: hosted
version: "10.1.2"
device_info_plus_platform_interface:
dependency: transitive
description:
name: device_info_plus_platform_interface
sha256: e1ea89119e34903dca74b883d0dd78eb762814f97fb6c76f35e9ff74d261a18f
url: "https://pub.dev"
source: hosted
version: "7.0.3"
equatable:
dependency: transitive
description:
@ -622,7 +638,7 @@ packages:
source: hosted
version: "2.0.1"
http:
dependency: transitive
dependency: "direct main"
description:
name: http
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
@ -1418,6 +1434,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "5.15.0"
win32_registry:
dependency: transitive
description:
name: win32_registry
sha256: "21ec76dfc731550fd3e2ce7a33a9ea90b828fdf19a5c3bcf556fa992cfa99852"
url: "https://pub.dev"
source: hosted
version: "1.1.5"
xdg_directories:
dependency: transitive
description:

View File

@ -41,7 +41,9 @@ dependencies:
hive_flutter: ^1.1.0
google_generative_ai: ^0.4.7
image_picker: ^1.0.7
image_picker: ^1.1.2
device_info_plus: ^10.1.0
http: ^1.2.0
lucide_icons: ^0.257.0
reorderable_grid_view: ^2.2.5
camera: ^0.11.3

BIN
screenshot.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

25
tools/check_models.dart Normal file
View File

@ -0,0 +1,25 @@
import 'package:google_generative_ai/google_generative_ai.dart';
import '../lib/secrets.dart';
import 'dart:io';
void main() async {
final apiKey = Secrets.geminiApiKey;
// Using a model that definitely exists to get the client initialized,
// though listModels doesn't technically require a specific model instance usually in REST,
// the SDK might just use the apiKey.
// Actually the SDK doesn't expose listModels directly on GenerativeModel?
// Let's check if we can simply use the REST API via Dart's http or just try to init a model and query.
// Wait, the google_generative_ai package usually doesn't have a static listModels method exposed easily in top level?
// Let's check source code if possible, or just use curl.
// Using curl via Process.run is easier/safer if I don't want to depend on package imports that might fail.
print("Checking models via curl...");
final result = await Process.run('curl', [
'-s',
'https://generativelanguage.googleapis.com/v1beta/models?key=$apiKey'
]);
print(result.stdout);
print(result.stderr);
}

41
tools/list_models_v2.dart Normal file
View File

@ -0,0 +1,41 @@
import 'package:google_generative_ai/google_generative_ai.dart';
import '../lib/secrets.dart';
void main() async {
final apiKey = Secrets.geminiApiKey;
print('Checking models for API Key: ${apiKey.substring(0, 5)}...');
// We can't easily "list" models with the package unless we use the REST API manually or specific helper.
// Actually, google_generative_ai doesn't have a 'listModels' helper easily accessible in the logic.
// We will brute-force check the likely candidates.
final candidates = [
'gemini-1.5-flash',
'gemini-1.5-flash-001',
'gemini-1.5-flash-latest',
'gemini-1.5-pro',
'gemini-1.5-pro-001',
'gemini-1.0-pro',
'gemini-2.0-flash-exp',
'gemini-2.5-flash', // The one that worked before
'gemini-pro',
];
for (final modelName in candidates) {
print('\n----------------------------------------');
print('Testing Model: $modelName');
try {
final model = GenerativeModel(model: modelName, apiKey: apiKey);
final response = await model.generateContent([Content.text('Test')]);
print('✅ AVAILABLE. Response: ${response.text?.trim()}');
} catch (e) {
if (e.toString().contains('404') || e.toString().contains('not found')) {
print('❌ 404 NOT FOUND');
} else if (e.toString().contains('429') || e.toString().contains('Quota')) {
print('⚠️ 429 QUOTA EXCEEDED (But Model Exists!)');
} else {
print('❌ ERROR: $e');
}
}
}
}

View File

@ -215,7 +215,7 @@ async def analyze_sake_label(request: AnalyzeRequest):
"name": "銘柄名(例:獺祭 純米大吟醸)",
"brand": "蔵元名(例:旭酒造)",
"prefecture": "都道府県名(例:山口県)",
"type": "種類(例:純米大吟醸",
"type": "特定名称(例:純米大吟醸, 本醸造, 普通酒 など",
"description": "この日本酒の特徴を100文字程度で説明裏ラベルの情報があれば活用してください",
"catchCopy": "この日本酒を一言で表現する詩的なキャッチコピー20文字以内",
"confidenceScore": 0から100の整数,
@ -226,7 +226,13 @@ async def analyze_sake_label(request: AnalyzeRequest):
"acidity": 3,
"bitterness": 3,
"body": 3
}
},
"alcoholContent": アルコール度数数値のみ: 15.5,
"polishingRatio": 精米歩合整数のみ: 50,
"sakeMeterValue": 日本酒度数値: +3.0, -1.5,
"riceVariety": "酒米(例:山田錦、五百万石)",
"yeast": "酵母きょうかい9号、M310",
"manufacturingYearMonth": "製造年月2023.10, 2023年10月"
}
**tasteStatsの説明 (1-5の整数)**:

View File

@ -0,0 +1,36 @@
import 'package:google_generative_ai/google_generative_ai.dart';
import '../lib/secrets.dart';
void main() async {
final apiKey = Secrets.geminiApiKey;
final modelsToTest = [
'gemini-2.0-flash', // Should work based on listing
'gemini-2.5-flash', // Should fail? Or work if it was working before
'models/gemini-2.0-flash',
'gemini-1.5-flash', // Retesting
'gemini-1.5-pro',
'gemini-1.5-flash-8b',
];
print('Starting Expanded Model Verification...');
for (final modelName in modelsToTest) {
try {
print('\nTesting: $modelName');
final model = GenerativeModel(model: modelName, apiKey: apiKey);
final response = await model.generateContent([Content.text('Hello')]);
print('✅ SUCCESS: $modelName');
print('Response: ${response.text?.trim()}');
} catch (e) {
print('❌ FAILED: $modelName');
final msg = e.toString();
if (msg.contains('not found') || msg.contains('404')) {
print('Error: Model Not Found (404)');
} else {
// Print first 200 chars to debug other errors (like Quota)
print('Error: ${msg.substring(0, msg.length > 200 ? 200 : msg.length)}...');
}
}
}
}