diff --git a/.claude/settings.local.json b/.claude/settings.local.json index fe8f899..f5e9c4c 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -18,7 +18,8 @@ "Bash(flutter build:*)", "Bash(unzip:*)", "Bash(ls:*)", - "Bash(awk:*)" + "Bash(awk:*)", + "Bash(flutter pub:*)" ], "deny": [], "ask": [] diff --git a/CommonSpecification_bk.md b/CommonSpecification_bk.md deleted file mode 100644 index 53436dd..0000000 --- a/CommonSpecification_bk.md +++ /dev/null @@ -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** diff --git a/CommonSpecification_gemini.md b/CommonSpecification_gemini.md deleted file mode 100644 index aa7b416..0000000 --- a/CommonSpecification_gemini.md +++ /dev/null @@ -1,113 +0,0 @@ -CommonSpecification.md (v1.0) -1. vWFNgTv -Av: ۂ[ށiPon-Roomj - -RZvg: {́uL^ÉEzvxAIAvB - -^[Qbg: - B2C: ʃ[U[i̓{̌L^EffELj - -B2B: HXiX̓{j[PDF/QRtō쐬Ej - -2. ZpX^bN -Frontend: Flutter (iOS/Android/Web) - -Backend/DB: Cloud Firestore, Firebase Auth - -AI/ML: - Local OCR: Google ML Kit (eLXgo) - -LLM Analysis: Gemini API (2.5 Flash / 3 Flash) - -Library: - gal: J[ۑp - -pdf, printing: PDFEp - -qr_flutter: QRR[hp - -3. ʃf[^\ (JSON Schema) -ׂĂAI͂уf[^ۑ͂̌`ɏ]ƁB - -JSON - -{ - "display_data": { - "name": "", - "catch_phrase": "AĨLb`Rs[", - "image_path": "local/path/to/image.jpg", - "rating": 4.5 - }, - "hidden_specs": { - "brewery": "", - "prefecture": "s{", - "type": "薼(đ)", - "alcohol_content": 16.0, - "polishing_ratio": 23, - "rice_variety": "gp", - "sake_meter_value": 0.0, - "qr_code_url": "https://pon-room.app/sake/12345" - }, - "badges": { - "is_recommended": false, - "is_seasonal": false, - "season_tag": "t" - }, - "gamification": { - "pon_points": 10, - "sake_mbti_type": "t[eB[E_^" - }, - "user_data": { - "is_favorite": false, - "memo": "eCXeBO", - "created_at": "ISO8601`" - }, - "metadata": { - "app_type": "sake", - "version": "1.0" - } -} -4. @\ʃKChC -4.1 BeE̓t[ (Hybrid Analysis) -Be: ʐ^BeAɁuJ[i[̃M[jv֕ۑigalpbP[WgpjB - -OCR: Google ML Kitʼn摜琶eLXg𒊏oB - -AI: oeLXĝ݂Gemini֑MALJSON`ō\f[^擾B - -: 摜𒼐AIɑ̂ł͂ȂAeLXg邱ƂŃg[NߖƃG[sB - -4.2 UI\ (5^uE؂ւ) -^u1 (Xg): J[h`Bdisplay_datâ݂\Aɗ̓VvɕۂB - -^u2 (XL/쐬): - Consumer[h: QRXLiXj[̓ǂݎE|CgljB - -Business[h: PDFj[쐬iQRR[hߍ݁jB - -^u3 (AI/ff): AI\GA𑠃}bvigjB - -^u4 (}bv): 𑠏}bvijB - -^u5 (}Cy[W): - (ケ)J[h: MBTI̎ȐffʁAobWAl|Cg\B - -ݒ: ÉuԃACRvɃ[h؂ւAgKChAVXeݒWB - -4.3 I{[fBO (Onboarding) -N4̃XebvKCh\B - -eʉÉuHvACRŃKChĕ\B - -Business[h؂ւ͐p3XebvKChlj\B - -4.4 B2B/B2C zƒWbN -B2B: PDFo͎Ɋe qr_code_url QRR[hƂĖߍށB - -B2C: QRXLƁAڍ׏񂪕\u||Cgv܂dg݂zB - -5. JD揇 (Roadmap) -Phase 1: CommonSpecification.md ɊÂf[^f̍Ē`B - -Phase 2: J[ۑ@\̎if[^XNjB - -Phase 3: PDF + printing iAntigravity]pB2B@\jB - -Phase 4: OCR + Gemini APIAg̍œKB - -Phase 5: }Cy[WiJ[hjуQ[~tBP[VB \ No newline at end of file diff --git a/all_models.json b/all_models.json new file mode 100644 index 0000000..f690edc Binary files /dev/null and b/all_models.json differ diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 9de44d0..a01eada 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -54,5 +54,13 @@ + + + + + + + + diff --git a/lib/models/maps/japan_map_data.dart b/lib/models/maps/japan_map_data.dart index 2bdaf50..a54f3ad 100644 --- a/lib/models/maps/japan_map_data.dart +++ b/lib/models/maps/japan_map_data.dart @@ -6,42 +6,42 @@ class JapanMapData { // Designed to show Hokkaido size, Honshu curve, and close Kyushu/Shikoku static final List> 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], diff --git a/lib/models/maps/prefecture_tile_layout.dart b/lib/models/maps/prefecture_tile_layout.dart index 8953beb..03d05eb 100644 --- a/lib/models/maps/prefecture_tile_layout.dart +++ b/lib/models/maps/prefecture_tile_layout.dart @@ -20,69 +20,68 @@ class PrefectureTileLayout { static Map get getLayout => finalLayout; static const Map 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), }; } diff --git a/lib/models/sake_item.dart b/lib/models/sake_item.dart index 4dad9b8..2dd2e85 100644 --- a/lib/models/sake_item.dart +++ b/lib/models/sake_item.dart @@ -235,6 +235,14 @@ class SakeItem extends HiveObject { double? markup, Map? 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, diff --git a/lib/providers/menu_providers.dart b/lib/providers/menu_providers.dart index c19055c..e248292 100644 --- a/lib/providers/menu_providers.dart +++ b/lib/providers/menu_providers.dart @@ -93,7 +93,7 @@ class PdfIncludePriceNotifier extends Notifier { final pdfDensityProvider = NotifierProvider(PdfDensityNotifier.new); class PdfDensityNotifier extends Notifier { @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; } diff --git a/lib/providers/ui_experiment_provider.dart b/lib/providers/ui_experiment_provider.dart index d3ede56..d71fe69 100644 --- a/lib/providers/ui_experiment_provider.dart +++ b/lib/providers/ui_experiment_provider.dart @@ -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 { void setFabAnimation(String animation) { state = state.copyWith(fabAnimation: animation); } + + void setMapColorful(bool isColorful) { + state = state.copyWith(isMapColorful: isColorful); + } } diff --git a/lib/screens/camera_screen.dart b/lib/screens/camera_screen.dart index 34e0640..8d3aaaa 100644 --- a/lib/screens/camera_screen.dart +++ b/lib/screens/camera_screen.dart @@ -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 with SingleTickerPr Future _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 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,8 +683,18 @@ class _CameraScreenState extends ConsumerState with SingleTickerPr ), ), - // Right Spacer (Balance) - const SizedBox(width: 48), + // 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), ], ), ), diff --git a/lib/screens/dev_menu_screen.dart b/lib/screens/dev_menu_screen.dart index e6b809f..e79f2f6 100644 --- a/lib/screens/dev_menu_screen.dart +++ b/lib/screens/dev_menu_screen.dart @@ -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), + ), ], ), ), diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index f0d0b28..8495caf 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -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(); } diff --git a/lib/screens/menu_creation_screen.dart b/lib/screens/menu_creation_screen.dart index 956caab..1080efe 100644 --- a/lib/screens/menu_creation_screen.dart +++ b/lib/screens/menu_creation_screen.dart @@ -147,7 +147,7 @@ class MenuCreationScreen extends ConsumerWidget { ), ); } else if (isListActuallyEmpty) { - return const HomeEmptyState(); + return const HomeEmptyState(isMenuMode: true); } else { return const SakeNoMatchState(); } diff --git a/lib/screens/menu_settings_screen.dart b/lib/screens/menu_settings_screen.dart index 9d51b60..69e09c4 100644 --- a/lib/screens/menu_settings_screen.dart +++ b/lib/screens/menu_settings_screen.dart @@ -260,8 +260,8 @@ class _MenuSettingsScreenState extends ConsumerState { 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; diff --git a/lib/screens/placeholders/brewery_map_screen.dart b/lib/screens/placeholders/brewery_map_screen.dart index 64cd965..b0a88c4 100644 --- a/lib/screens/placeholders/brewery_map_screen.dart +++ b/lib/screens/placeholders/brewery_map_screen.dart @@ -66,7 +66,10 @@ class _BreweryMapScreenState extends ConsumerState { 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, '制覇済'), ], diff --git a/lib/screens/sake_detail_screen.dart b/lib/screens/sake_detail_screen.dart index 93234c8..a8473d5 100644 --- a/lib/screens/sake_detail_screen.dart +++ b/lib/screens/sake_detail_screen.dart @@ -80,6 +80,7 @@ class _SakeDetailScreenState extends ConsumerState { 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 { 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 { 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('sake_items'); diff --git a/lib/screens/shop_settings_screen.dart b/lib/screens/shop_settings_screen.dart index 71e7396..090dced 100644 --- a/lib/screens/shop_settings_screen.dart +++ b/lib/screens/shop_settings_screen.dart @@ -26,14 +26,14 @@ class _ShopSettingsScreenState extends ConsumerState { 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( diff --git a/lib/screens/soul_screen.dart b/lib/screens/soul_screen.dart index acc6461..32c6e8f 100644 --- a/lib/screens/soul_screen.dart +++ b/lib/screens/soul_screen.dart @@ -96,7 +96,7 @@ class _SoulScreenState extends ConsumerState { // other Settings const OtherSettingsSection( - title: 'データ・その他', + title: 'その他', ), const SizedBox(height: 24), diff --git a/lib/secrets - コピー.dart b/lib/secrets - コピー.dart new file mode 100644 index 0000000..74a315f --- /dev/null +++ b/lib/secrets - コピー.dart @@ -0,0 +1,3 @@ +class Secrets { + static const String geminiApiKey = 'AIzaSyD9FubZhzrDKFbFtGqBQdfOuP1QTZ5vJf4'; +} diff --git a/lib/secrets_bk.dart b/lib/secrets_bk.dart new file mode 100644 index 0000000..74a315f --- /dev/null +++ b/lib/secrets_bk.dart @@ -0,0 +1,3 @@ +class Secrets { + static const String geminiApiKey = 'AIzaSyD9FubZhzrDKFbFtGqBQdfOuP1QTZ5vJf4'; +} diff --git a/lib/services/device_service.dart b/lib/services/device_service.dart new file mode 100644 index 0000000..856e3b9 --- /dev/null +++ b/lib/services/device_service.dart @@ -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 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; + } +} diff --git a/lib/services/gemini_service.dart b/lib/services/gemini_service.dart index c0cbbac..eecc356 100644 --- a/lib/services/gemini_service.dart +++ b/lib/services/gemini_service.dart @@ -1,334 +1,180 @@ 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; - - // レート制限対策: 最後のAPI呼び出し時刻を記録 + // AI Proxy Server Configuration + static const String _proxyUrl = 'http://192.168.31.89:8080/analyze'; + + // レート制限対策? 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 analyzeSakeLabel(List imagePaths) async { + // サーバー側のデフォルトプロンプトを使用するため、customPromptはnullを送信 + return _callProxyApi( + imagePaths: imagePaths, + customPrompt: null, ); } - Future analyzeSakeLabel(List 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 = [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 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 analyzeSakeHybrid(String extractedText, List imagePaths) async { + final prompt = ''' +以下のOCR抽出テキストと添付の日本酒ラベル画像を組み合わせて分析してください。 +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: imagePaths, customPrompt: prompt); + } + + /// テキストのみの解析 (画像なし) + Future 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 _callProxyApi({ + required List 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 || imagePaths.isEmpty) throw Exception("テキストまたは画像がありません"); - - debugPrint('Analyzing hybrid (Text: ${extractedText.length} chars, Images: ${imagePaths.length})...'); - - final prompt = ''' -以下のOCR抽出テキストと添付の日本酒ラベル画像を組み合わせて分析してください。 -OCRの性質上、テキストには「Data 29」が「Daia 2Y」になるような誤字や脱落が含まれますが、 -**添付の画像で実際の表記を確認し、正しい情報を推測・補完**してください。 - -特に以下の点に注目して分析してください: -1. **銘柄名・特定名称**: テキストで断片的な情報(例:"KIMOTO")があれば、画像で全体のバランスを見て正式な商品名(例:"KIMOTO 35")を特定してください。 -2. **信頼度**: テキストと画像の両方があるため、**高い信頼度(90以上)**を目指してください。矛盾がある場合は画像の情報を優先してください。 - -抽出テキスト: -""" -$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 parts = [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}'); + // 2. 画像をBase64変換 + List 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(); - - final Map json = jsonDecode(jsonText); + // 3. デバイスID取得 + final deviceId = await DeviceService.getDeviceId(); + debugPrint('Device ID: $deviceId'); - return SakeAnalysisResult.fromJson(json); + // 4. リクエスト作成 + final requestBody = jsonEncode({ + "device_id": deviceId, + "images": base64Images, + "prompt": customPrompt, + }); + + 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; } } - - Future analyzeSakeText(String extractedText) async { - try { - // レート制限対策 - 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}'); - } - - final jsonText = text.trim() - .replaceAll('```json', '') - .replaceAll('```', '') - .trim(); - - final Map json = jsonDecode(jsonText); - - return SakeAnalysisResult.fromJson(json); - - } catch (e) { - _handleError(e); - 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 @@ -342,6 +188,14 @@ class SakeAnalysisResult { final int? confidenceScore; final List flavorTags; final Map tasteStats; + + // New Fields + final double? alcoholContent; + final int? polishingRatio; + final double? sakeMeterValue; + final String? riceVariety; + final String? yeast; + final String? manufacturingYearMonth; SakeAnalysisResult({ this.name, @@ -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 json) { @@ -373,6 +233,12 @@ class SakeAnalysisResult { confidenceScore: json['confidenceScore'] as int?, flavorTags: (json['flavorTags'] as List?)?.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?, ); } } diff --git a/lib/widgets/home/home_empty_state.dart b/lib/widgets/home/home_empty_state.dart index b2cdf88..241139a 100644 --- a/lib/widgets/home/home_empty_state.dart +++ b/lib/widgets/home/home_empty_state.dart @@ -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], diff --git a/lib/widgets/map/pixel_japan_map.dart b/lib/widgets/map/pixel_japan_map.dart deleted file mode 100644 index 4dfd469..0000000 --- a/lib/widgets/map/pixel_japan_map.dart +++ /dev/null @@ -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 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), - ), - ), - ), - ), - ); - } -} diff --git a/lib/widgets/map/prefecture_tile_map.dart b/lib/widgets/map/prefecture_tile_map.dart index e0eed6d..449b0a7 100644 --- a/lib/widgets/map/prefecture_tile_map.dart +++ b/lib/widgets/map/prefecture_tile_map.dart @@ -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 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 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, diff --git a/lib/widgets/sake_radar_chart.dart b/lib/widgets/sake_radar_chart.dart index b498d50..bddf805 100644 --- a/lib/widgets/sake_radar_chart.dart +++ b/lib/widgets/sake_radar_chart.dart @@ -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, diff --git a/lib/widgets/settings/backup_settings_section.dart b/lib/widgets/settings/backup_settings_section.dart index b6374da..97fc585 100644 --- a/lib/widgets/settings/backup_settings_section.dart +++ b/lib/widgets/settings/backup_settings_section.dart @@ -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( diff --git a/models.txt b/models.txt new file mode 100644 index 0000000..f690edc Binary files /dev/null and b/models.txt differ diff --git a/pubspec.lock b/pubspec.lock index 0cf55d0..2243a06 100644 --- a/pubspec.lock +++ b/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: diff --git a/pubspec.yaml b/pubspec.yaml index 76af514..3f1b9cb 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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 diff --git a/screenshot.png b/screenshot.png new file mode 100644 index 0000000..09013d9 Binary files /dev/null and b/screenshot.png differ diff --git a/tools/check_models.dart b/tools/check_models.dart new file mode 100644 index 0000000..3c70379 --- /dev/null +++ b/tools/check_models.dart @@ -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); +} diff --git a/tools/list_models_v2.dart b/tools/list_models_v2.dart new file mode 100644 index 0000000..160bc27 --- /dev/null +++ b/tools/list_models_v2.dart @@ -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'); + } + } + } +} diff --git a/tools/synology/ai-proxy/server.py b/tools/synology/ai-proxy/server.py index fd01757..8427418 100644 --- a/tools/synology/ai-proxy/server.py +++ b/tools/synology/ai-proxy/server.py @@ -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の整数)**: diff --git a/tools/test_generation.dart b/tools/test_generation.dart new file mode 100644 index 0000000..e00b825 --- /dev/null +++ b/tools/test_generation.dart @@ -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)}...'); + } + } + } +}