feat: enhance AI spec extraction
This commit is contained in:
parent
a5353a9b50
commit
6507ab9596
|
|
@ -18,7 +18,8 @@
|
|||
"Bash(flutter build:*)",
|
||||
"Bash(unzip:*)",
|
||||
"Bash(ls:*)",
|
||||
"Bash(awk:*)"
|
||||
"Bash(awk:*)",
|
||||
"Bash(flutter pub:*)"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
|
|
|
|||
|
|
@ -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**
|
||||
|
|
@ -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: マイページ(酒向カード)およびゲーミフィケーション実装。
|
||||
Binary file not shown.
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -147,7 +147,7 @@ class MenuCreationScreen extends ConsumerWidget {
|
|||
),
|
||||
);
|
||||
} else if (isListActuallyEmpty) {
|
||||
return const HomeEmptyState();
|
||||
return const HomeEmptyState(isMenuMode: true);
|
||||
} else {
|
||||
return const SakeNoMatchState();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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, '制覇済'),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -96,7 +96,7 @@ class _SoulScreenState extends ConsumerState<SoulScreen> {
|
|||
|
||||
// other Settings
|
||||
const OtherSettingsSection(
|
||||
title: 'データ・その他',
|
||||
title: 'その他',
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
class Secrets {
|
||||
static const String geminiApiKey = 'AIzaSyD9FubZhzrDKFbFtGqBQdfOuP1QTZ5vJf4';
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
class Secrets {
|
||||
static const String geminiApiKey = 'AIzaSyD9FubZhzrDKFbFtGqBQdfOuP1QTZ5vJf4';
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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": 0から100の整数,
|
||||
"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 29」が「Daia 2Y」になるような誤字や脱落が含まれますが、
|
||||
**添付の画像で実際の表記を確認し、正しい情報を推測・補完**してください。
|
||||
OCRの性質上、テキストには誤字や脱落が含まれますが、添付の画像で実際の表記を確認し、正しい情報を推測・補完してください。
|
||||
|
||||
特に以下の点に注目して分析してください:
|
||||
1. **銘柄名・特定名称**: テキストで断片的な情報(例:"KIMOTO")があれば、画像で全体のバランスを見て正式な商品名(例:"KIMOTO 35")を特定してください。
|
||||
2. **信頼度**: テキストと画像の両方があるため、**高い信頼度(90以上)**を目指してください。矛盾がある場合は画像の情報を優先してください。
|
||||
テキストで断片的な情報があれば、画像で全体のバランスを見て正式な商品名を特定してください。
|
||||
|
||||
抽出テキスト:
|
||||
"""
|
||||
|
|
@ -162,173 +39,142 @@ $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
|
||||
"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": 0から100の整数,
|
||||
"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?,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Binary file not shown.
26
pubspec.lock
26
pubspec.lock
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Binary file not shown.
|
After Width: | Height: | Size: 206 KiB |
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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の整数)**:
|
||||
|
|
|
|||
|
|
@ -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)}...');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue