commit 5f2802728dcfe0dfea3f35e876363244e491a12f Author: Ponshu Developer Date: Sun Jan 11 17:17:29 2026 +0900 v1.0.8 - Original (Ponshu Room Lite MVP Complete) diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..ac07d5b --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,9 @@ +{ + "permissions": { + "allow": [ + "Bash(Get-ChildItem libwidgets -Filter *.dart -Recurse)" + ], + "deny": [], + "ask": [] + } +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fef837e --- /dev/null +++ b/.gitignore @@ -0,0 +1,48 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ +/coverage/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release + +# Security +lib/secrets.dart diff --git a/.metadata b/.metadata new file mode 100644 index 0000000..08cb0a9 --- /dev/null +++ b/.metadata @@ -0,0 +1,45 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "19074d12f7eaf6a8180cd4036a430c1d76de904e" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: 19074d12f7eaf6a8180cd4036a430c1d76de904e + base_revision: 19074d12f7eaf6a8180cd4036a430c1d76de904e + - platform: android + create_revision: 19074d12f7eaf6a8180cd4036a430c1d76de904e + base_revision: 19074d12f7eaf6a8180cd4036a430c1d76de904e + - platform: ios + create_revision: 19074d12f7eaf6a8180cd4036a430c1d76de904e + base_revision: 19074d12f7eaf6a8180cd4036a430c1d76de904e + - platform: linux + create_revision: 19074d12f7eaf6a8180cd4036a430c1d76de904e + base_revision: 19074d12f7eaf6a8180cd4036a430c1d76de904e + - platform: macos + create_revision: 19074d12f7eaf6a8180cd4036a430c1d76de904e + base_revision: 19074d12f7eaf6a8180cd4036a430c1d76de904e + - platform: web + create_revision: 19074d12f7eaf6a8180cd4036a430c1d76de904e + base_revision: 19074d12f7eaf6a8180cd4036a430c1d76de904e + - platform: windows + create_revision: 19074d12f7eaf6a8180cd4036a430c1d76de904e + base_revision: 19074d12f7eaf6a8180cd4036a430c1d76de904e + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/ANTIGRAVITY_PROMPT.md b/ANTIGRAVITY_PROMPT.md new file mode 100644 index 0000000..96cca42 --- /dev/null +++ b/ANTIGRAVITY_PROMPT.md @@ -0,0 +1,691 @@ +# Antigravity向け - 新生ぽんるーむ実装プロンプト + +**実装日**: 2025-12-29以降 +**実装担当**: Antigravity +**サポート**: Claude (Anthropic) + +--- + +## 🚨 最重要指示 + +### ⛔ 絶対にやってはいけないこと + +``` +❌ 既存のWeb版(ponshu-room/lib/)からコードを1行もコピーしない +❌ 既存プロジェクトを編集しない +❌ Web版のUIを踏襲しない +``` + +### ✅ やるべきこと + +``` +✅ 完全に新しいFlutterプロジェクトを作成 +✅ FINAL_REQUIREMENTS.mdに100%従う +✅ スキャンアプリ(mai_quick_scan)の成功パターンを参考にする +✅ 写真を主役にする +``` + +--- + +## 📱 プロジェクト作成(コピペして実行) + +### ステップ1: 新規プロジェクト作成 + +```bash +# 親ディレクトリへ移動 +cd C:\Users\maita\posimai-project + +# 完全に新しいFlutterプロジェクトを作成 +flutter create ponshu_room_reborn + +cd ponshu_room_reborn +``` + +### ステップ2: pubspec.yaml編集 + +**完全に置き換え**: + +```yaml +name: ponshu_room_reborn +description: "Reborn Ponshu Room - My Digital Sake Cellar" +publish_to: 'none' +version: 2.0.0+1 + +environment: + sdk: ^3.10.1 + +dependencies: + flutter: + sdk: flutter + + # 状態管理(スキャンアプリと同じ) + flutter_riverpod: ^2.6.1 + hooks_riverpod: ^2.6.1 + flutter_hooks: ^0.20.5 + + # ローカルDB + hive: ^2.2.3 + hive_flutter: ^1.1.0 + + # AI解析 + google_generative_ai: ^0.4.7 + http: ^1.6.0 + + # カメラ・画像 + camera: ^0.11.0+2 + image_picker: ^1.2.1 + image: ^4.3.0 + + # UI + google_fonts: ^6.2.1 + fl_chart: ^1.1.1 + + # 共有・保存 + share_plus: ^12.0.1 + path_provider: ^2.1.5 + + # その他 + intl: ^0.20.2 + package_info_plus: ^9.0.0 + url_launcher: ^6.3.2 + countries_world_map: ^1.3.0 + cupertino_icons: ^1.0.8 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^6.0.0 + hive_generator: ^2.0.1 + build_runner: ^2.4.13 + +flutter: + uses-material-design: true +``` + +```bash +flutter pub get +``` + +### ステップ3: Android設定 + +`android/app/build.gradle.kts` を編集: + +```kotlin +android { + namespace = "com.posimai.ponshu_room" + compileSdk = 36 // Android 15+ 対応 + + defaultConfig { + applicationId = "com.posimai.ponshu_room" + minSdk = 21 + targetSdk = 35 + versionCode = 1 + versionName = "2.0.0" + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_17.toString() + } + + buildTypes { + release { + signingConfig = signingConfigs.getByName("debug") + } + } +} +``` + +--- + +## 🎨 デザイン仕様(最重要) + +### 写真を主役にする「ナロー・マージン」設計 + +#### ❌ 従来の余白設計(ダメな例) + +```dart +ListView.separated( + padding: EdgeInsets.all(16), // ← 余白が大きすぎ + separatorBuilder: (context, index) => SizedBox(height: 16), // ← 隙間が大きすぎ + itemBuilder: (context, index) => SakeCard(...), +) +``` + +**問題点**: 写真が小さくなる、迫力がない + +#### ✅ 新生ぽんるーむの余白設計(正しい例) + +```dart +ListView.separated( + padding: EdgeInsets.symmetric(horizontal: 8, vertical: 12), // ← 最小限 + separatorBuilder: (context, index) => SizedBox(height: 4), // ← 細い隙間 + itemBuilder: (context, index) => SakeCard(...), +) +``` + +**メリット**: 写真が大きい、迫力がある、Instagram的 + +### カードデザイン - 「フル幅カード」 + +```dart +// lib/widgets/sake_card.dart +class SakeCard extends StatelessWidget { + final SakeItem item; + + const SakeCard({required this.item}); + + @override + Widget build(BuildContext context) { + final screenWidth = MediaQuery.of(context).size.width; + + return Card( + margin: EdgeInsets.zero, // ← カード自体のマージンはゼロ + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(0), // ← 角丸なし(フル幅) + side: BorderSide( + color: Color(0xFFE8E8E8), + width: 0.5, // ← 繊細なボーダー + ), + ), + child: Container( + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: Color(0xFFE8E8E8), + width: 0.5, + ), + ), + ), + child: Padding( + padding: EdgeInsets.all(12), // ← カード内の余白は最小限 + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 左: 写真(できるだけ大きく) + ClipRRect( + borderRadius: BorderRadius.circular(8), // ← 写真だけ角丸 + child: Image.network( + item.imagePath, + width: 120, // ← 大きく! + height: 120, + fit: BoxFit.cover, + ), + ), + SizedBox(width: 12), + // 右: テキスト情報 + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 銘柄名(明朝体、大きく) + Text( + item.brandName ?? '日本酒', + style: GoogleFonts.notoSerifJp( + fontSize: 18, + fontWeight: FontWeight.w600, + color: Color(0xFF1A1A1A), + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + SizedBox(height: 4), + // 酒蔵・産地 + Text( + '${item.breweryName ?? ''} | ${item.prefecture ?? ''}', + style: GoogleFonts.notoSansJp( + fontSize: 11, + color: Color(0xFF8A8A8A), + ), + ), + SizedBox(height: 8), + // 評価 + if (item.rating != null) + Row( + children: List.generate( + 5, + (index) => Icon( + index < item.rating!.round() + ? Icons.star + : Icons.star_border, + size: 16, + color: Color(0xFFFFC107), + ), + ), + ), + SizedBox(height: 4), + // 種類 + if (item.type != null) + Text( + item.type!, + style: GoogleFonts.notoSansJp( + fontSize: 11, + color: Color(0xFF4A4A4A), + ), + ), + // キャッチコピー + if (item.catchCopy != null) ...[ + SizedBox(height: 6), + Text( + item.catchCopy!, + style: GoogleFonts.notoSerifJp( + fontSize: 12, + fontStyle: FontStyle.italic, + color: Color(0xFF376495), + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + ], + ), + ), + ], + ), + ), + ), + ); + } +} +``` + +**レイアウトイメージ**: +``` +┌────────────────────────────────────┐ +│ [120x120] │ 獺祭 ← 明朝体18px +│ 写真 │ 旭酒造 | 山口県 ← ゴシック体11px +│ 角丸8px │ ⭐⭐⭐⭐⭐ 純米大吟醸 +│ │ "夜風と楽しみたい、淡麗な一滴" +└────────────────────────────────────┘ + ↑ 0.5pxのボーダーで区切り +``` + +--- + +## 🍶 Gemini 3.0統合(核心機能) + +### APIキー設定 + +```dart +// lib/secrets.dart +class Secrets { + static const String geminiApiKey = 'AIzaSyA2BSr16R2k0bHjSYcSUdmLoY8PKwaFts0'; +} +``` + +### リアルタイム実況付きGemini解析 + +```dart +// lib/services/gemini_service.dart +import 'dart:typed_data'; +import 'package:flutter/material.dart'; +import 'package:google_generative_ai/google_generative_ai.dart'; +import 'dart:convert'; +import '../secrets.dart'; + +class GeminiService { + late final GenerativeModel _model; + + GeminiService() { + _model = GenerativeModel( + model: 'gemini-2.5-flash-latest', // スキャンアプリで実績あり + apiKey: Secrets.geminiApiKey, + generationConfig: GenerationConfig( + temperature: 0.1, + ), + ); + } + + /// リアルタイム実況付きで解析 + Stream analyzeSakeLabelWithCommentary(Uint8List imageBytes) async* { + // ステージ1: 開始 + yield 'ラベルを読んでいます...'; + await Future.delayed(Duration(milliseconds: 500)); + + // ステージ2: 解析中 + yield 'お酒の情報を確認しています...'; + + try { + final prompt = ''' +この画像は日本酒のボトルまたはラベルの写真です。 +ラベルに書かれているテキスト情報を正確に読み取り、JSON形式で返してください。 + +【重要な指示】 +1. ラベルに明確に書かれている情報のみを抽出してください +2. 推測や想像で値を入れないでください(読めない場合はnullまたは省略) +3. 数値は必ず数字のみ(単位記号%などは除く) +4. このお酒の印象を一言で表す「キャッチコピー」を自動生成してください + +【出力フォーマット】 +{ + "brandName": "銘柄名", + "type": "特定名称", + "alcoholContent": 数値, + "polishingRatio": 数値, + "breweryName": "酒蔵名", + "prefecture": "都道府県名", + "catchCopy": "夜風と楽しみたい、淡麗な一滴" +} + +**JSON以外の余計な説明は一切不要です。JSONのみを出力してください。** +'''; + + final content = [ + Content.multi([ + TextPart(prompt), + DataPart('image/jpeg', imageBytes), + ]) + ]; + + final response = await _model.generateContent(content); + final text = response.text ?? ''; + + // ステージ3: 都道府県を検出した場合 + if (text.contains('県')) { + final prefectureMatch = RegExp(r'([^\s]+県)').firstMatch(text); + if (prefectureMatch != null) { + yield 'お、これは${prefectureMatch.group(1)}の銘柄ですね...'; + await Future.delayed(Duration(milliseconds: 300)); + } + } + + // ステージ4: データ整理中 + yield 'データを整理しています...'; + await Future.delayed(Duration(milliseconds: 300)); + + // JSON抽出 + final jsonMatch = RegExp(r'\{[\s\S]*\}').firstMatch(text); + if (jsonMatch != null) { + final jsonString = jsonMatch.group(0)!; + final data = jsonDecode(jsonString); + + // ステージ5: 完了 + yield '解析完了!'; + + // データを返す(特別な形式) + yield 'DATA:$jsonString'; + } else { + yield 'エラー: データを読み取れませんでした'; + } + } catch (e) { + yield 'エラー: $e'; + } + } + + /// シンプルな解析(実況なし) + Future?> analyzeSakeLabel(Uint8List imageBytes) async { + try { + final prompt = ''' +この画像は日本酒のボトルまたはラベルの写真です。 +ラベルに書かれているテキスト情報を正確に読み取り、JSON形式で返してください。 + +【重要な指示】 +1. ラベルに明確に書かれている情報のみを抽出してください +2. 推測や想像で値を入れないでください(読めない場合はnullまたは省略) +3. 数値は必ず数字のみ(単位記号%などは除く) +4. このお酒の印象を一言で表す「キャッチコピー」を自動生成してください + +【出力フォーマット】 +{ + "brandName": "銘柄名", + "type": "特定名称", + "alcoholContent": 数値, + "polishingRatio": 数値, + "breweryName": "酒蔵名", + "prefecture": "都道府県名", + "catchCopy": "夜風と楽しみたい、淡麗な一滴" +} + +**JSON以外の余計な説明は一切不要です。JSONのみを出力してください。** +'''; + + final content = [ + Content.multi([ + TextPart(prompt), + DataPart('image/jpeg', imageBytes), + ]) + ]; + + final response = await _model.generateContent(content); + final text = response.text ?? ''; + + final jsonMatch = RegExp(r'\{[\s\S]*\}').firstMatch(text); + if (jsonMatch != null) { + final jsonString = jsonMatch.group(0)!; + return jsonDecode(jsonString); + } + + return null; + } catch (e) { + debugPrint('Gemini API Error: $e'); + return null; + } + } +} +``` + +### リアルタイム実況の使用例 + +```dart +// lib/screens/input_screen.dart(一部) +StreamBuilder( + stream: geminiService.analyzeSakeLabelWithCommentary(imageBytes), + builder: (context, snapshot) { + if (snapshot.hasData) { + final message = snapshot.data!; + + // データが返ってきた場合 + if (message.startsWith('DATA:')) { + final jsonString = message.substring(5); + final data = jsonDecode(jsonString); + // フォームに自動入力 + _fillForm(data); + return ResultForm(data: data); + } + + // 実況メッセージを表示 + return AnalyzingOverlay(currentStage: message); + } + + return AnalyzingOverlay(currentStage: 'カメラを起動しています...'); + }, +) +``` + +--- + +## 📦 データモデル + +### SakeItem (Hive Model) + +```dart +// lib/models/sake_item.dart +import 'package:hive/hive.dart'; + +part 'sake_item.g.dart'; + +@HiveType(typeId: 0) +class SakeItem extends HiveObject { + @HiveField(0) + String? brandName; + + @HiveField(1) + String? breweryName; + + @HiveField(2) + String? prefecture; + + @HiveField(3) + String? type; + + @HiveField(4) + double? alcoholContent; + + @HiveField(5) + int? polishingRatio; + + @HiveField(6) + String imagePath; + + @HiveField(7) + List? additionalImages; + + @HiveField(8) + double? rating; + + @HiveField(9) + String? memo; + + @HiveField(10) + List? tags; + + @HiveField(11) + bool isFavorite; + + @HiveField(12) + bool isWishlist; + + @HiveField(13) + DateTime createdAt; + + @HiveField(14) + DateTime? updatedAt; + + @HiveField(15) + int? price; + + @HiveField(16) + int? volume; + + @HiveField(17) + String? catchCopy; // AIが生成したキャッチコピー + + @HiveField(18) + double? sweetnessScore; // -1.0(辛口)~ 1.0(甘口) + + @HiveField(19) + double? bodyScore; // -1.0(淡麗)~ 1.0(濃醇) + + SakeItem({ + this.brandName, + this.breweryName, + this.prefecture, + this.type, + this.alcoholContent, + this.polishingRatio, + required this.imagePath, + this.additionalImages, + this.rating, + this.memo, + this.tags, + this.isFavorite = false, + this.isWishlist = false, + required this.createdAt, + this.updatedAt, + this.price, + this.volume, + this.catchCopy, + this.sweetnessScore, + this.bodyScore, + }); +} +``` + +```bash +# 生成コマンド +flutter pub run build_runner build +``` + +--- + +## 🚀 実装チェックリスト + +### Phase 1: MVP(5時間) + +- [ ] プロジェクト作成(`flutter create ponshu_room_reborn`) +- [ ] `pubspec.yaml` 設定 +- [ ] Android設定(compileSdk: 36, targetSdk: 35) +- [ ] `lib/secrets.dart` 作成(APIキー) +- [ ] `lib/models/sake_item.dart` 作成 +- [ ] `flutter pub run build_runner build` +- [ ] `lib/theme/app_theme.dart` 作成(posimaiカラー) +- [ ] `lib/main.dart` 作成(Hive初期化) +- [ ] `lib/screens/home/home_screen.dart` 作成(4タブ) +- [ ] `lib/services/gemini_service.dart` 作成(リアルタイム実況) +- [ ] `lib/widgets/sake_card.dart` 作成(フル幅カード) +- [ ] `lib/widgets/analyzing_overlay.dart` 作成(実況UI) +- [ ] カメラ撮影機能 +- [ ] 入力フォーム +- [ ] 詳細画面 +- [ ] **SafeArea対応** ← Android 15必須 + +### Phase 2: 「美録」UI洗練(3時間) + +- [ ] 余白調整(padding: 8dp, separator: 4dp) +- [ ] 明朝体×ゴシック体の適用 +- [ ] Hero遷移 +- [ ] Instagram用画像生成機能 + +### Phase 3: 「遊び心」機能拡張(4時間) + +- [ ] フレーバー・マトリックス +- [ ] 日本酒・制覇マップ + バッジ +- [ ] AIソムリエ(質問例、チャット) +- [ ] マイページ統計グラフ + +### Phase 4: 共有機能(2時間) + +- [ ] シンプルテキスト共有 +- [ ] Instagram用正方形画像生成 +- [ ] キャッチコピー付き共有 + +--- + +## ✅ 完成基準 + +### MVP完成の定義 +- [ ] カメラで日本酒を撮影できる +- [ ] Gemini 3.0でリアルタイム実況しながら解析 +- [ ] キャッチコピーが自動生成される +- [ ] データをHiveに保存できる +- [ ] フル幅カード(写真120x120)で表示 +- [ ] 詳細表示できる +- [ ] SafeAreaで見切れない +- [ ] Android 15 (Xiaomi 14T Pro) で動作する + +### 最終完成の定義 +- [ ] すべての機能が動作 +- [ ] フレーバー・マトリックス表示 +- [ ] 日本酒・制覇マップ + バッジ +- [ ] Instagram用正方形画像生成 +- [ ] 「雑誌のような」デザイン +- [ ] 60fpsの滑らかな動作 +- [ ] 「魔法のような」心地よさ + +--- + +## 📞 進捗報告 + +各フェーズ完了後に以下を報告してください: + +``` +Phase 1-1 完了: プロジェクト初期化 +Phase 1-2 完了: データモデル +... + +問題点: +- XXXでエラーが発生しました +- YYYの実装方法が不明です + +質問: +- ZZZはどう実装すべきですか? +``` + +--- + +**頑張ってください!「魔法のような体験」を一緒に作りましょう!🍶✨** diff --git a/CommonSpecification.md b/CommonSpecification.md new file mode 100644 index 0000000..53436dd --- /dev/null +++ b/CommonSpecification.md @@ -0,0 +1,510 @@ +# ぽんるーむ (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_bk.md b/CommonSpecification_bk.md new file mode 100644 index 0000000..53436dd --- /dev/null +++ b/CommonSpecification_bk.md @@ -0,0 +1,510 @@ +# ぽんるーむ (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 new file mode 100644 index 0000000..aa7b416 --- /dev/null +++ b/CommonSpecification_gemini.md @@ -0,0 +1,113 @@ +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/FINAL_REQUIREMENTS.md b/FINAL_REQUIREMENTS.md new file mode 100644 index 0000000..c57c63d --- /dev/null +++ b/FINAL_REQUIREMENTS.md @@ -0,0 +1,923 @@ +# 新生ぽんるーむ - 最終完全要件定義書 + +**プロジェクト名**: 新生ぽんるーむ (Reborn Ponshu Room) +**バージョン**: 2.0 - "My Digital Sake Cellar" +**作成日**: 2025-12-29 +**設計**: Claude (Anthropic) + Gemini (Google AI) + posimai +**実装担当**: Antigravity + Claude + +--- + +## 🎯 プロジェクトビジョン + +> **「目の前の一本を撮るだけで、魔法のようにデータが溜まっていく」** + +### 3つの核心コンセプト + +#### 🍶 1. 「瞬撮」- 魔法の解析体験 +カメラを向けて撮るだけで、AIが全てを読み取る。 +ユーザーはただ「撮る」だけ。 + +#### 🎨 2. 「美録」- インスタ映えする情報の見せ方 +雑誌のようなレイアウトで、いつでも見返したくなる。 +そのままインスタに投稿できる美しさ。 + +#### 🧩 3. 「遊び心」- 意味のあるデータ分析 +自分の好みを「発見」する楽しさ。 +日本全国制覇マップ、フレーバーマトリックス。 + +--- + +## 📱 技術スタック + +### 必須要件 +```yaml +Flutter SDK: 3.38.3+ +Dart: 3.10.1+ +Android: + compileSdk: 36 + targetSdk: 35 (Android 15対応) + minSdk: 21 (Android 5.0+) +``` + +### 依存パッケージ + +```yaml +dependencies: + flutter: + sdk: flutter + + # 状態管理(Riverpod - スキャンアプリで実績あり) + flutter_riverpod: ^2.6.1 + hooks_riverpod: ^2.6.1 + flutter_hooks: ^0.20.5 + + # ローカルDB + hive: ^2.2.3 + hive_flutter: ^1.1.0 + + # AI解析(Gemini 3.0) + google_generative_ai: ^0.4.7 + http: ^1.6.0 + + # カメラ・画像 + camera: ^0.11.0+2 + image_picker: ^1.2.1 + image: ^4.3.0 + + # UI + google_fonts: ^6.2.1 + fl_chart: ^1.1.1 + + # 共有・保存 + share_plus: ^12.0.1 + path_provider: ^2.1.5 + + # その他 + intl: ^0.20.2 + package_info_plus: ^9.0.0 + url_launcher: ^6.3.2 + countries_world_map: ^1.3.0 + cupertino_icons: ^1.0.8 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^6.0.0 + hive_generator: ^2.0.1 + build_runner: ^2.4.13 +``` + +--- + +## 🎨 UI/UXデザイン原則 + +### デザインコンセプト +**「雑誌のような洗練、魔法のような心地よさ」** + +### カラーパレット + +```dart +// posimaiブランドカラー +static const Color posimaiBlue = Color(0xFF376495); + +// ベースカラー +static const Color warmOffWhite = Color(0xFFFAFAF9); // 背景 +static const Color richBlack = Color(0xFF1A1A1A); // テキスト +static const Color charcoalGray = Color(0xFF4A4A4A); // サブテキスト +static const Color warmGray = Color(0xFF8A8A8A); // 補助テキスト + +// アクセントカラー +static const Color dustyPink = Color(0xFFE8B4B8); // お気に入り +static const Color softYellow = Color(0xFFFEF3C7); // ウィッシュリスト +``` + +### タイポグラフィ + +```dart +// 銘柄名・タイトル: 明朝体(格調高く) +headlineMedium: GoogleFonts.notoSerifJp( + fontSize: 20, + fontWeight: FontWeight.w600, + letterSpacing: 0.5, + color: richBlack, +) + +// データ・ボディ: ゴシック体(読みやすく) +bodyMedium: GoogleFonts.notoSansJp( + fontSize: 13, + fontWeight: FontWeight.w400, + color: charcoalGray, +) + +// ラベル・キャプション: ゴシック体 +bodySmall: GoogleFonts.notoSansJp( + fontSize: 11, + fontWeight: FontWeight.w500, + color: warmGray, +) +``` + +--- + +## 🍶 1. 「瞬撮」- 魔法の解析体験 + +### APIキー設定 + +```dart +// lib/secrets.dart +class Secrets { + static const String geminiApiKey = 'AIzaSyA2BSr16R2k0bHjSYcSUdmLoY8PKwaFts0'; +} +``` + +### Gemini 3.0統合 + +**重要**: 以下のいずれかを使用 + +```dart +// オプション1: 最新の3.0プレビュー(推奨) +model: 'gemini-3.0-flash-latest' + +// オプション2: 安定版2.5 Flash(スキャンアプリで実績) +model: 'gemini-2.5-flash-latest' +``` + +### AIソムリエのリアルタイム実況 + +**解析中のUI**: + +```dart +class AnalyzingOverlay extends StatelessWidget { + final String currentStage; + + const AnalyzingOverlay({required this.currentStage}); + + @override + Widget build(BuildContext context) { + return Container( + color: Colors.black.withOpacity(0.7), + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CircularProgressIndicator( + valueColor: AlwaysStoppedAnimation(posimaiBlue), + ), + SizedBox(height: 24), + Text( + currentStage, + style: GoogleFonts.notoSansJp( + fontSize: 16, + color: Colors.white, + ), + ), + ], + ), + ), + ); + } +} +``` + +**ステージの例**: +```dart +[ + 'ラベルを読んでいます...', + 'お、これは〇〇県の銘柄ですね...', + '精米歩合を確認中...', + 'データを整理しています...', +] +``` + +### 自動ポエム生成 + +Geminiのレスポンスに以下を追加: + +```dart +final prompt = ''' +この画像は日本酒のボトルまたはラベルの写真です。 +ラベルに書かれているテキスト情報を正確に読み取り、JSON形式で返してください。 + +【重要な指示】 +1. ラベルに明確に書かれている情報のみを抽出してください +2. 推測や想像で値を入れないでください(読めない場合はnullまたは省略) +3. 数値は必ず数字のみ(単位記号%などは除く) +4. このお酒の印象を一言で表す「キャッチコピー」を自動生成してください + +【出力フォーマット】 +{ + "brandName": "銘柄名", + "type": "特定名称", + "alcoholContent": 数値, + "polishingRatio": 数値, + "breweryName": "酒蔵名", + "prefecture": "都道府県名", + "catchCopy": "夜風と楽しみたい、淡麗な一滴" // ← 自動生成 +} + +**JSON以外の余計な説明は一切不要です。JSONのみを出力してください。** +'''; +``` + +--- + +## 🎨 2. 「美録」- インスタ映えする情報の見せ方 + +### モダン・カタログ・カード + +**Web版の縦並びを捨て、左右2段構成を採用**: + +```dart +// lib/widgets/sake_card.dart +class SakeCard extends StatelessWidget { + final SakeItem item; + + const SakeCard({required this.item}); + + @override + Widget build(BuildContext context) { + return Card( + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: BorderSide(color: Color(0xFFE8E8E8), width: 1), + ), + child: Padding( + padding: EdgeInsets.all(16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 左: 写真(角丸12px) + ClipRRect( + borderRadius: BorderRadius.circular(12), + child: Image.network( + item.imagePath, + width: 100, + height: 100, + fit: BoxFit.cover, + ), + ), + SizedBox(width: 16), + // 右: テキスト情報 + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 銘柄名(明朝体、大きく) + Text( + item.brandName ?? '日本酒', + style: GoogleFonts.notoSerifJp( + fontSize: 18, + fontWeight: FontWeight.w600, + color: richBlack, + ), + ), + SizedBox(height: 4), + // 酒蔵・産地(ゴシック体、控えめ) + Text( + '${item.breweryName ?? ''} | ${item.prefecture ?? ''}', + style: GoogleFonts.notoSansJp( + fontSize: 11, + color: warmGray, + ), + ), + SizedBox(height: 8), + // 評価 + StarRating(rating: item.rating ?? 0), + SizedBox(height: 4), + // 種類 + Text( + item.type ?? '', + style: GoogleFonts.notoSansJp( + fontSize: 11, + color: charcoalGray, + ), + ), + // キャッチコピー(NEW!) + if (item.catchCopy != null) ...[ + SizedBox(height: 8), + Text( + item.catchCopy!, + style: GoogleFonts.notoSerifJp( + fontSize: 12, + fontStyle: FontStyle.italic, + color: posimaiBlue, + ), + ), + ], + ], + ), + ), + ], + ), + ), + ); + } +} +``` + +### インスタ専用・共有カード生成 + +**正方形(1:1)の画像を自動生成**: + +```dart +// lib/services/instagram_share_service.dart +import 'dart:ui' as ui; +import 'package:flutter/material.dart'; +import 'package:image/image.dart' as img; + +class InstagramShareService { + static Future generateInstagramImage(SakeItem item) async { + // 1. 画像を読み込み + final imageBytes = await File(item.imagePath).readAsBytes(); + final image = img.decodeImage(imageBytes)!; + + // 2. 正方形(1080x1080)にクロップ + final size = 1080; + final square = img.copyResizeCropSquare(image, size: size); + + // 3. 下半分にposimaiカラーのオーバーレイ + final overlay = img.Image(width: size, height: size ~/ 2); + img.fillRect( + overlay, + x1: 0, + y1: 0, + x2: size, + y2: size ~/ 2, + color: img.ColorRgb8(55, 100, 149), // posimaiBlue + ); + + // 4. オーバーレイを合成 + img.compositeImage( + square, + overlay, + dstX: 0, + dstY: size ~/ 2, + ); + + // 5. テキストを描画(銘柄名、評価、ハッシュタグ) + // TODO: 白抜き明朝体でテキスト描画 + + // 6. 保存 + final directory = await getTemporaryDirectory(); + final path = '${directory.path}/instagram_${DateTime.now().millisecondsSinceEpoch}.jpg'; + await File(path).writeAsBytes(img.encodeJpg(square)); + + return path; + } + + static Future shareToInstagram(SakeItem item) async { + // インスタ用画像を生成 + final imagePath = await generateInstagramImage(item); + + // 共有 + await Share.shareXFiles( + [XFile(imagePath)], + text: ''' +🍶 ${item.brandName ?? '日本酒'} + +${item.catchCopy ?? ''} + +#ぽんるーむ #日本酒 #${item.prefecture ?? ''} #日本酒好きと繋がりたい +''', + ); + } +} +``` + +**レイアウトイメージ**: +``` +┌────────────────────┐ +│ │ +│ [日本酒ラベル写真] │ ← 上半分(540px) +│ │ +├────────────────────┤ +│ posimaiブルー背景 │ ← 下半分(540px) +│ │ +│ 獺祭(明朝体・白) │ ← 銘柄名 +│ ⭐⭐⭐⭐⭐ │ ← 評価 +│ │ +│ 夜風と楽しみたい、 │ ← キャッチコピー +│ 淡麗な一滴 │ +│ │ +│ #ぽんるーむ │ ← ハッシュタグ +│ [logo] │ ← posimaiロゴ(右下) +└────────────────────┘ +``` + +--- + +## 🧩 3. 「遊び心」- 意味のあるデータ分析 + +### フレーバー・マトリックス + +**4象限チャートで味の傾向を可視化**: + +``` + 甘口 + ↑ + │ +濃醇 ←─┼─→ 淡麗 + │ + ↓ + 辛口 +``` + +**実装**: + +```dart +// lib/widgets/flavor_matrix.dart +class FlavorMatrix extends StatelessWidget { + final List items; + + const FlavorMatrix({required this.items}); + + @override + Widget build(BuildContext context) { + // AIが解析したフレーバータグから傾向を計算 + // 例: "甘口"タグが多い → 甘口寄り + // "フルーティー"タグが多い → 淡麗寄り + + return Container( + height: 200, + child: CustomPaint( + painter: FlavorMatrixPainter( + userPosition: _calculateUserPosition(), + ), + ), + ); + } + + Offset _calculateUserPosition() { + // ユーザーの好みを計算 + // 甘口/辛口、濃醇/淡麗の2軸で位置を決定 + return Offset(0.3, 0.6); // 例: やや甘口、やや淡麗 + } +} +``` + +### 日本酒・制覇マップ + +**都道府県マップをposimaiカラーで塗りつぶし**: + +```dart +// lib/screens/home/map_tab.dart +SimpleMap( + instructions: SMapJapan.instructions, + defaultColor: warmGray, // 未踏破 + colors: SMapJapanColors( + // データから自動計算 + ..._prefectureColors(), + ).toMap(), + callback: (id, name, tapDetails) { + // タップで詳細表示 + _showPrefectureDetail(name); + }, +) + +Map _prefectureColors() { + final counts = _countByPrefecture(); + return counts.map((prefecture, count) { + if (count == 0) return MapEntry(prefecture, warmGray); + if (count < 3) return MapEntry(prefecture, posimaiBlue.withOpacity(0.3)); + return MapEntry(prefecture, posimaiBlue); + }); +} +``` + +**バッジ表示**: + +```dart +// マップの上部に表示 +Container( + padding: EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(8), + ), + child: Column( + children: [ + Text( + '制覇: ${_completedPrefectures()} / 47', + style: GoogleFonts.notoSerifJp( + fontSize: 24, + fontWeight: FontWeight.bold, + color: posimaiBlue, + ), + ), + SizedBox(height: 8), + Text( + 'あと${47 - _completedPrefectures()}県で全国制覇!', + style: GoogleFonts.notoSansJp( + fontSize: 13, + color: charcoalGray, + ), + ), + ], + ), +) +``` + +### Synology バックアップ・ステータス + +**マイページの右上に控えめに表示**: + +```dart +// lib/screens/home/profile_tab.dart +Positioned( + top: 16, + right: 16, + child: Container( + padding: EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: Colors.green[50], + borderRadius: BorderRadius.circular(16), + border: Border.all(color: Colors.green[200]!), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.cloud_done, size: 16, color: Colors.green[700]), + SizedBox(width: 4), + Text( + 'Home Lab Sync: OK', + style: GoogleFonts.notoSansJp( + fontSize: 11, + color: Colors.green[700], + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), +) +``` + +--- + +## 📦 データモデル(更新版) + +### SakeItem (Hive Model) + +**キャッチコピーを追加**: + +```dart +import 'package:hive/hive.dart'; + +part 'sake_item.g.dart'; + +@HiveType(typeId: 0) +class SakeItem extends HiveObject { + // 基本情報 + @HiveField(0) + String? brandName; + + @HiveField(1) + String? breweryName; + + @HiveField(2) + String? prefecture; + + @HiveField(3) + String? type; + + @HiveField(4) + double? alcoholContent; + + @HiveField(5) + int? polishingRatio; + + // 画像 + @HiveField(6) + String imagePath; + + @HiveField(7) + List? additionalImages; + + // 評価・メモ + @HiveField(8) + double? rating; + + @HiveField(9) + String? memo; + + @HiveField(10) + List? tags; + + // フラグ + @HiveField(11) + bool isFavorite; + + @HiveField(12) + bool isWishlist; + + // 日時 + @HiveField(13) + DateTime createdAt; + + @HiveField(14) + DateTime? updatedAt; + + // 価格(オプション) + @HiveField(15) + int? price; + + @HiveField(16) + int? volume; + + // 🆕 AIが生成したキャッチコピー + @HiveField(17) + String? catchCopy; + + // 🆕 フレーバープロファイル(甘口/辛口、濃醇/淡麗) + @HiveField(18) + double? sweetnessScore; // -1.0(辛口)~ 1.0(甘口) + + @HiveField(19) + double? bodyScore; // -1.0(淡麗)~ 1.0(濃醇) + + SakeItem({ + this.brandName, + this.breweryName, + this.prefecture, + this.type, + this.alcoholContent, + this.polishingRatio, + required this.imagePath, + this.additionalImages, + this.rating, + this.memo, + this.tags, + this.isFavorite = false, + this.isWishlist = false, + required this.createdAt, + this.updatedAt, + this.price, + this.volume, + this.catchCopy, + this.sweetnessScore, + this.bodyScore, + }); +} +``` + +--- + +## 🚀 実装優先順位 + +### Phase 1: MVP(5時間) + +#### チェックリスト +- [ ] プロジェクト初期化(`flutter create ponshu_room_reborn`) +- [ ] Android設定(compileSdk: 36, targetSdk: 35) +- [ ] 依存関係追加 +- [ ] Hiveセットアップ +- [ ] SakeItemモデル(キャッチコピー含む) +- [ ] posimaiテーマ +- [ ] ホーム画面骨組み(4タブ) +- [ ] **Gemini 3.0解析 + リアルタイム実況** +- [ ] カメラ撮影 +- [ ] 入力フォーム +- [ ] 詳細画面 +- [ ] SafeArea対応 + +### Phase 2: 「美録」UI洗練(3時間) + +#### チェックリスト +- [ ] モダン・カタログ・カード(左右2段構成) +- [ ] 明朝体×ゴシック体の適用 +- [ ] インスタ専用画像生成機能 +- [ ] Hero遷移 +- [ ] アニメーション(200-300ms) + +### Phase 3: 「遊び心」機能拡張(4時間) + +#### チェックリスト +- [ ] フレーバー・マトリックス +- [ ] 日本酒・制覇マップ + バッジ +- [ ] Synologyバックアップ・ステータス表示 +- [ ] AIソムリエ(質問例、チャット形式) +- [ ] マイページ統計グラフ +- [ ] 検索・フィルタ・ソート + +### Phase 4: 共有機能(2時間) + +#### チェックリスト +- [ ] シンプルテキスト共有 +- [ ] Instagram用正方形画像生成 +- [ ] キャッチコピー付き共有 + +**合計所要時間**: 14時間 + +--- + +## 📐 画面構成 + +### ホーム画面(HomeScreen) + +#### ボトムナビゲーション +``` +┌─────┬─────┬─────┬─────┐ +│ 🍶 │ 🗺️ │ 🤖 │ 👤 │ +│ 酒 │ マップ│ AI │ MY │ +└─────┴─────┴─────┴─────┘ +``` + +#### タブ1: 酒リスト(ListTab) + +**モダン・カタログ・カード**: +``` +┌────────────────────────────┐ +│ [100x100] │ 獺祭 │ ← 明朝体、大きく +│ 写真 │ 旭酒造 | 山口県 │ ← ゴシック体、控えめ +│ 角丸12px │ ⭐⭐⭐⭐⭐ 純米大吟醸 │ +│ │ "夜風と楽しみたい、淡麗な一滴" │ ← キャッチコピー +└────────────────────────────┘ +``` + +#### タブ2: マップ(MapTab) + +**日本酒・制覇マップ**: +- 未踏破: warmGray +- 3本未満: posimaiBlue (30% opacity) +- 3本以上: posimaiBlue (100%) +- バッジ: 「制覇: 5 / 47」「あと42県で全国制覇!」 + +#### タブ3: AIソムリエ(AiTab) + +**質問例ボタン**: +```dart +Wrap( + spacing: 8, + children: [ + OutlinedButton( + child: Text('純米大吟醸とは?'), + onPressed: () => _askAI('純米大吟醸について詳しく教えて'), + ), + OutlinedButton( + child: Text('山田錦について教えて'), + onPressed: () => _askAI('山田錦という酒米の特徴を教えて'), + ), + OutlinedButton( + child: Text('刺身に合う日本酒は?'), + onPressed: () => _askAI('刺身に合う日本酒のおすすめを教えて'), + ), + OutlinedButton( + child: Text('初心者におすすめの銘柄'), + onPressed: () => _askAI('日本酒初心者におすすめの銘柄を教えて'), + ), + ], +) +``` + +#### タブ4: マイページ(ProfileTab) + +**セクション構成**: + +1. **Synologyバックアップステータス**(右上) + ``` + 🏠 Home Lab Sync: OK + ``` + +2. **酒蔵サマリー** + ``` + ┌─────────┬─────────┬─────────┐ + │ 1 │ 0 │ 0 │ + │ 飲んだ本数│お気に入り│ 買いたい │ + └─────────┴─────────┴─────────┘ + ``` + +3. **フレーバー・マトリックス** + ``` + 甘口 + ↑ + │ ●(あなた) + 濃醇 ←─┼─→ 淡麗 + │ + ↓ + 辛口 + + あなたが選ぶ酒は、フルーティーな甘口に偏っています + ``` + +4. **よく飲む都道府県** + ``` + 🥇 青森県 ━━━━━━━━━━ 1本 + ``` + +5. **飲酒傾向グラフ** + - 月別飲酒本数(直近6ヶ月) + - 評価分布(1-5星) + +--- + +## 🔐 プライバシー・セキュリティ + +### データ保存場所 + +#### デフォルト(全ユーザー) +``` +✅ ローカル(Hive DB)のみ +✅ 写真はアプリ専用ディレクトリ +❌ 外部サーバーへの送信なし +``` + +#### オプション(posimai専用) +``` +✅ Synology NAS連携(WebDAV/FTP) +✅ 自動バックアップ +✅ "Home Lab Sync: OK" ステータス表示 +``` + +--- + +## 📄 ファイル構成 + +``` +lib/ +├── main.dart +├── secrets.dart +├── models/ +│ ├── sake_item.dart +│ └── sake_item.g.dart +├── providers/ +│ ├── sake_repository_provider.dart +│ ├── gemini_provider.dart +│ └── camera_provider.dart +├── services/ +│ ├── hive_service.dart +│ ├── gemini_service.dart +│ ├── share_service.dart +│ └── instagram_share_service.dart ← 🆕 +├── screens/ +│ ├── home/ +│ │ ├── home_screen.dart +│ │ ├── list_tab.dart +│ │ ├── map_tab.dart +│ │ ├── ai_tab.dart +│ │ └── profile_tab.dart +│ ├── detail_screen.dart +│ └── input_screen.dart +├── widgets/ +│ ├── sake_card.dart ← 🆕 左右2段構成 +│ ├── star_rating.dart +│ ├── prefecture_dropdown.dart +│ ├── tag_chip.dart +│ ├── flavor_matrix.dart ← 🆕 +│ └── analyzing_overlay.dart ← 🆕 リアルタイム実況 +└── theme/ + └── app_theme.dart +``` + +--- + +## ✅ 完成基準 + +### MVP完成の定義 +- [ ] カメラで日本酒を撮影できる +- [ ] Gemini 3.0でリアルタイム実況しながら解析 +- [ ] キャッチコピーが自動生成される +- [ ] データをHiveに保存できる +- [ ] モダン・カタログ・カード(左右2段)で表示 +- [ ] 詳細表示できる +- [ ] SafeAreaで見切れない +- [ ] Android 15 (Xiaomi 14T Pro) で動作する + +### 最終完成の定義 +- [ ] すべての機能が動作 +- [ ] フレーバー・マトリックス表示 +- [ ] 日本酒・制覇マップ + バッジ +- [ ] Instagram用正方形画像生成 +- [ ] Synologyバックアップステータス表示 +- [ ] 「雑誌のような」デザイン +- [ ] 60fpsの滑らかな動作 +- [ ] 「魔法のような」心地よさ + +--- + +**最終更新**: 2025-12-29 +**設計**: Claude (Anthropic) + Gemini (Google AI) + posimai +**実装担当**: Antigravity + Claude + +**Let's create the magic! 🍶✨** diff --git a/GEMINI_PRO_SETUP.md b/GEMINI_PRO_SETUP.md new file mode 100644 index 0000000..22271f7 --- /dev/null +++ b/GEMINI_PRO_SETUP.md @@ -0,0 +1,177 @@ +# Gemini Pro API セットアップガイド + +## 現在の状況 + +### Google One Proについて +**重要:** Google One Pro会員の特典とGemini APIの料金は**別物**です。 + +- **Google One Pro特典**: Gmail、Docs、DriveでのGemini機能が使える +- **Gemini API (開発者向け)**: アプリ開発用のAPI、無料枠と有料枠がある + +**このアプリで使用しているのは開発者向けGemini APIです。** + +--- + +## API制限の解除方法 + +### 方法1: 時間を待つ(無料) +現在レート制限エラーが出ている場合: + +1. **1-2分待つ** - RPM(15回/分)制限は1分で解除 +2. **アプリを再起動** - 内部カウンターがリセット +3. **5秒以上間隔を空けて解析** - 自動的に待機するようになりました + +### 方法2: 別のGoogleアカウントを使用 +新しいAPIキーを取得: + +1. 別のGoogleアカウントでログイン +2. [Google AI Studio](https://aistudio.google.com/apikey) にアクセス +3. 新しいAPIキーを生成 +4. `lib/secrets.dart` のAPIキーを差し替え +5. アプリを再ビルド + +**注意:** 同じIPアドレスから使用すると制限が共有される可能性があります。 + +--- + +## Gemini API 有料プラン(Pay-as-you-go)への移行 + +### 重要な説明 + +**有料プラン ≠ より高性能なモデル** + +有料プランにすると得られるのは: +- ✅ **RPM(リクエスト数)制限の大幅緩和**: 15回/分 → 1,000回/分 +- ✅ **TPM(トークン数)制限の緩和**: 100万/分 → 400万/分 +- ❌ **モデルの性能向上ではない** + +**モデルの違い:** + +| モデル | 特徴 | 速度 | 精度 | 料金 | +|--------|------|------|------|------| +| **gemini-2.5-flash** (現在使用中) | 高速・軽量 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | 無料枠: 15RPM
有料: $0.075/1M入力 | +| **gemini-2.5-pro** | 高精度・重い | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ | 無料枠: 2RPM
有料: $1.25/1M入力 | + +**推奨:** +- **このアプリでは `gemini-2.5-flash` で十分な精度**が出ています +- モデルは変更せず、**有料プランに移行するだけで制限が解除**されます +- Pro版は約17倍高額なので、必要性がない限り不要 + +### 料金体系(Pay-as-you-go) + +#### gemini-2.5-flash(推奨) +- **無料版**: 15回/分、100万トークン/分 +- **有料版**: 1,000回/分、400万トークン/分 +- **料金**: $0.075/100万入力トークン、$0.30/100万出力トークン + +**実際のコスト例:** +- 日本酒画像1枚解析 ≒ 5万トークン入力 = **約$0.004 (約0.6円)** +- 100枚解析しても **約60円** + +#### gemini-2.5-pro(高精度が必要な場合のみ) +- **無料版**: 2回/分、3.2万トークン/分 +- **有料版**: 1,000回/分、400万トークン/分 +- **料金**: $1.25/100万入力トークン(flashの約17倍) + +### 有料版への移行手順 + +#### 1. Google AI Studioで課金設定 +``` +1. https://aistudio.google.com/ にアクセス +2. 左メニュー「Billing」をクリック +3. 「Enable Pay-as-you-go」を選択 +4. クレジットカード情報を登録 +5. 利用上限を設定(例: 月$10まで) +``` + +#### 2. アプリのモデル設定(変更不要) +`lib/services/gemini_service.dart` の18行目: + +```dart +// 現在の設定(推奨: このまま) +static const String _modelName = 'gemini-2.5-flash'; + +// より高精度が必要な場合のみ(17倍高額) +// static const String _modelName = 'gemini-2.5-pro'; +``` + +**注意:** モデルを変更しなくても、課金設定するだけで**制限が大幅に緩和**されます + +#### 3. アプリを再ビルド(モデル変更時のみ) +```bash +# モデルを変更した場合のみ必要 +flutter clean +flutter build apk --release +``` + +**注意:** 課金設定だけなら**再ビルド不要**です。同じAPKで制限が緩和されます。 + +--- + +## 現在の制限対策(無料版) + +アプリに実装済みの対策: + +### 1. 自動レート制限保護 +- **5秒間隔の強制**: 連続解析時に自動的に待機 +- トークン消費量のログ出力(デバッグ時) + +### 2. 画像サイズの最適化 +- カメラ解像度: **high (1080p) → medium (720p)** +- ファイルサイズ約50%削減 +- 認識精度は維持 + +### 3. ユーザー向け情報表示 +- ホーム画面に「ℹ️」アイコン追加 +- API制限の詳細説明 +- 推奨事項の表示 + +### 4. 詳細なエラーメッセージ +``` +AI使用制限に達しました。 +無料版は1分間に15回までの制限があります。 +1〜2分後に再度お試しください。 +``` + +--- + +## おすすめの運用方法 + +### 無料版で運用する場合 +- ✅ **5秒以上間隔を空けて解析**(自動化済み) +- ✅ 同じ画像を再解析しない +- ✅ エラーが出たら1-2分待つ +- ✅ 1日あたり100-200枚程度まで + +### 有料版に移行する場合(推奨) +- ✅ 月数百円で制限をほぼ気にせず使える +- ✅ RPM 1,000回/分 → 実質無制限 +- ✅ ビジネス利用に最適 + +--- + +## トラブルシューティング + +### Q: 新しいAPIキーでもすぐエラーが出る +A: 同じIPアドレスから利用している可能性があります。モバイルデータ通信に切り替えてテストしてください。 + +### Q: Google One Proで無制限にならないの? +A: Google One ProはGmail/Docs用の特典です。API料金は別途発生します。 + +### Q: 有料版にしたらいくらかかる? +A: 1日10枚程度なら月100円以下、100枚/日でも月700円程度です。 + +### Q: APIキーが流出したらどうなる? +A: Google AI Studioで即座に無効化し、新しいキーを発行してください。 + +--- + +## 参考リンク + +- [Google AI Studio](https://aistudio.google.com/) +- [Gemini API料金表](https://ai.google.dev/pricing) +- [API使用量の確認](https://aistudio.google.com/quota) + +--- + +**最終更新:** 2025-12-31 diff --git a/README.md b/README.md new file mode 100644 index 0000000..956e737 --- /dev/null +++ b/README.md @@ -0,0 +1,16 @@ +# ponshu_room_lite + +A new Flutter project. + +## Getting Started + +This project is a starting point for a Flutter application. + +A few resources to get you started if this is your first Flutter project: + +- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab) +- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook) + +For help getting started with Flutter development, view the +[online documentation](https://docs.flutter.dev/), which offers tutorials, +samples, guidance on mobile development, and a full API reference. diff --git a/START_HERE.md b/START_HERE.md new file mode 100644 index 0000000..c0b07cd --- /dev/null +++ b/START_HERE.md @@ -0,0 +1,177 @@ +# 🚀 新生ぽんるーむ - スタートガイド + +**プロジェクト名**: 新生ぽんるーむ (Reborn Ponshu Room) +**作成日**: 2025-12-29 +**このフォルダの状態**: 完全にクリーン(設計書のみ) + +--- + +## ✅ このフォルダの状況 + +``` +現在のフォルダ: C:\Users\maita\posimai-project\ponshu_room_reborn + +含まれるもの: +✅ FINAL_REQUIREMENTS.md(最終仕様書) +✅ ANTIGRAVITY_PROMPT.md(実装手順書) +✅ UI_UX_DECISION_GUIDE.md(デザイン決定ガイド) +✅ START_HERE.md(このファイル) + +含まれないもの: +❌ 古いWeb版のコード(完全に除外) +❌ lib/ フォルダ +❌ pubspec.yaml +❌ 何のコードもありません +``` + +**これは意図的です。完全にゼロから始めます。** + +--- + +## 🎯 Antigravity向け - 最終プロンプト + +以下をAntigravityに送ってください: + +--- + +### 📝 Antigravityへの指示 + +``` +@Antigravity + +新生ぽんるーむをゼロから実装します。 +このフォルダ(ponshu_room_reborn)は完全にクリーンです。 + +📂 現在のフォルダ: +C:\Users\maita\posimai-project\ponshu_room_reborn + +📄 読み込むべきドキュメント: +1. ANTIGRAVITY_PROMPT.md(メイン実装手順) +2. FINAL_REQUIREMENTS.md(完全仕様書) +3. UI_UX_DECISION_GUIDE.md(デザインガイド) + +🔑 新しいAPIキー: +AIzaSyA2BSr16R2k0bHjSYcSUdmLoY8PKwaFts0 + +🎨 デザイン方針: +- 余白は最小限(padding: 8dp, separator: 4dp) +- 写真を主役にする(120x120) +- フル幅カード +- 極細ボーダー(0.5px)で区切り + +⚠️ 絶対にやってはいけないこと: +❌ ../ponshu-room/ フォルダを参照しない +❌ 古いコードをコピーしない +❌ Web版のUIを踏襲しない + +✅ やるべきこと: +1. このフォルダで flutter create . を実行 +2. ANTIGRAVITY_PROMPT.md の手順に従って実装 +3. Gemini 2.5-flash-latest または gemini-3.0-flash-latest を使用 +4. リアルタイム実況付きAI解析を実装 +5. SafeAreaを徹底使用(Android 15対応) + +🚀 開始: +ANTIGRAVITY_PROMPT.md の「プロジェクト作成」セクションから開始してください。 +Phase 1-1(プロジェクト初期化)完了後に報告をお願いします。 +``` + +--- + +## 📋 実装の流れ + +### Phase 0: 準備(今ここ) +- [x] 新しいフォルダ作成 +- [x] 設計書をコピー +- [ ] Antigravityに指示を送る + +### Phase 1: MVP(5時間) +- [ ] プロジェクト初期化(`flutter create .`) +- [ ] pubspec.yaml設定 +- [ ] Android設定(compileSdk: 36) +- [ ] secrets.dart作成(新APIキー) +- [ ] Hiveセットアップ +- [ ] Gemini解析(リアルタイム実況) +- [ ] フル幅カード +- [ ] SafeArea対応 + +### Phase 2: 美録(3時間) +- [ ] Instagram用画像生成 +- [ ] Hero遷移 + +### Phase 3: 遊び心(4時間) +- [ ] フレーバー・マトリックス +- [ ] 日本酒・制覇マップ + +### Phase 4: 共有(2時間) +- [ ] キャッチコピー付き共有 + +--- + +## 🔐 APIキーの設定 + +Antigravityが `lib/secrets.dart` を作成した後、以下が正しく設定されているか確認してください: + +```dart +// lib/secrets.dart +class Secrets { + static const String geminiApiKey = 'AIzaSyA2BSr16R2k0bHjSYcSUdmLoY8PKwaFts0'; +} +``` + +--- + +## ✅ 完成確認チェックリスト + +### MVP完成の確認 +- [ ] カメラで日本酒を撮影できる +- [ ] Gemini解析で「ラベルを読んでいます...」と表示される +- [ ] キャッチコピーが自動生成される +- [ ] フル幅カード(写真120x120)で表示される +- [ ] Android 15 (Xiaomi 14T Pro) で見切れない +- [ ] データがHiveに保存される + +### 最終完成の確認 +- [ ] すべての機能が動作 +- [ ] 「雑誌のような」デザイン +- [ ] 60fpsの滑らかな動作 +- [ ] 「魔法のような」心地よさ + +--- + +## 📞 トラブルシューティング + +### もしAntigravityが古いコードを参照しようとしたら + +**即座に指摘してください**: +``` +@Antigravity + +古い ponshu-room フォルダは参照しないでください。 +このフォルダ(ponshu_room_reborn)内のドキュメントだけを見てください。 +``` + +### もし余白が大きすぎたら + +**即座に修正を依頼してください**: +``` +@Antigravity + +余白が大きすぎます。以下に修正してください: +- ListView.padding: EdgeInsets.symmetric(horizontal: 8, vertical: 12) +- separatorBuilder: SizedBox(height: 4) +- 写真サイズ: 120x120 +``` + +--- + +## 🎊 準備完了! + +すべての準備が整いました。 + +**次のアクション**: +1. 上記の「Antigravityへの指示」をコピー +2. Antigravityに送信 +3. 実装開始を待つ + +**新生ぽんるーむの誕生を楽しみにしています!🍶✨** diff --git a/START_HERE_FINAL.md b/START_HERE_FINAL.md new file mode 100644 index 0000000..e434436 --- /dev/null +++ b/START_HERE_FINAL.md @@ -0,0 +1,397 @@ +# 🚀 ぽんるーむ Lite - 完全スタートガイド + +**プロジェクト名**: ぽんるーむ Lite (Ponshu Room Lite) +**バージョン**: 2.0 - "My Digital Sake Cellar with Personality" +**作成日**: 2025-12-29 +**このフォルダの状態**: 完全にクリーン(設計書のみ) + +--- + +## 💡 「Lite」の意味 + +**「Lite」= 機能制限ではなく、「洗練されたコア体験」** + +- ✅ 写真を主役にした雑誌のようなUI +- ✅ 完全ローカル保存(プライバシー最優先) +- ✅ MBTI・四柱推命と日本酒を掛け合わせた「あなただけの診断」 +- ✅ フォント切り替え機能(明朝体 ⇔ ゴシック体) + +**将来の「Pro」版**: +- Synology NAS連携 +- 高度な統計分析 +- チーム共有機能 + +--- + +## 🎯 このアプリの3つの魂 + +### 🍶 1. 「瞬撮」- 魔法の解析体験 +カメラを向けて撮るだけで、Gemini 3.0がリアルタイム実況しながら解析。 +``` +「ラベルを読んでいます...」 +「お、これは山口県の銘柄ですね...」 +「データを整理しています...」 +``` + +### 🎨 2. 「美録」- インスタ映えする情報の見せ方 +雑誌のようなレイアウト、選べるフォント、Instagram用正方形画像生成。 + +### 🧩 3. 「遊び心」- あなただけのパーソナライズ +- **MBTI × 日本酒相性診断**: 「このお酒はENFPのあなたにピッタリ!」 +- **四柱推命 × 今日の一滴**: 「今日の運気を上げる辛口な一本」 +- **フレーバー・マトリックス**: あなたの好みを可視化 + +--- + +## ✅ このフォルダの状況 + +``` +現在のフォルダ: C:\Users\maita\posimai-project\ponshu_room_lite + +含まれるもの: +✅ FINAL_REQUIREMENTS.md(最終仕様書) +✅ ANTIGRAVITY_PROMPT.md(実装手順書) +✅ UI_UX_DECISION_GUIDE.md(デザイン決定ガイド) +✅ START_HERE_FINAL.md(このファイル) + +含まれないもの: +❌ 古いWeb版のコード(完全に除外) +❌ lib/ フォルダ +❌ pubspec.yaml +❌ 何のコードもありません +``` + +**これは意図的です。完全にゼロから始めます。** + +--- + +## 🎯 Antigravity向け - 最終完全プロンプト + +以下をAntigravityに送ってください: + +--- + +``` +@Antigravity + +ぽんるーむ Lite をゼロから実装します。 +このフォルダ(ponshu_room_lite)は完全にクリーンです。 + +📂 現在のフォルダ: +C:\Users\maita\posimai-project\ponshu_room_lite + +📄 読み込むべきドキュメント: +1. START_HERE_FINAL.md(最初に読む) +2. ANTIGRAVITY_PROMPT.md(メイン実装手順) +3. FINAL_REQUIREMENTS.md(完全仕様書) + +🔑 新しいAPIキー: +AIzaSyA2BSr16R2k0bHjSYcSUdmLoY8PKwaFts0 + +🎨 デザイン方針(Lite版の魂): + +【写真最大化】 +- 余白は最小限(padding: 8dp, separator: 4dp) +- 写真サイズ: 120x120 +- フル幅カード +- 極細ボーダー(0.5px)で区切り + +【フォント戦略】 +- デフォルト: Noto Sans JP(親しみやすいゴシック体) +- 設定で切り替え可能: Noto Serif JP(高級感のある明朝体) +- ユーザーが好みで選べるようにする + +【パーソナライズ機能の準備】 +- UserProfileモデルを作成: + - MBTI診断結果(16タイプ) + - 生年月日(四柱推命用) + - 好みのフォント設定 +- これらは Phase 2以降で使用(今は枠だけ準備) + +⚠️ 絶対にやってはいけないこと: +❌ ../ponshu-room/ フォルダを参照しない +❌ 古いコードをコピーしない +❌ Web版のUIを踏襲しない + +✅ やるべきこと(Phase 1): + +【1-1. プロジェクト初期化】 +1. このフォルダで flutter create . を実行 +2. pubspec.yaml を設定(google_fonts, hive, riverpod, etc.) +3. Android設定(compileSdk: 36, targetSdk: 35) + +【1-2. データモデル】 +1. SakeItem モデル: + - 基本情報(銘柄名、酒蔵、都道府県、etc.) + - キャッチコピー(Geminiが自動生成) + - フレーバースコア(sweetnessScore, bodyScore) + +2. UserProfile モデル(🆕): + - mbtiType: String? // 例: "ENFP" + - birthDate: DateTime? // 四柱推命用 + - fontPreference: String // "serif" or "sans" + - createdAt: DateTime + +【1-3. テーマ設定】 +1. AppTheme を作成: + - posimaiカラー(#376495) + - 動的フォント切り替え対応 + - デフォルトは Noto Sans JP + +2. FontProvider(Riverpod)を作成: + - UserProfileからフォント設定を読み込み + - アプリ全体でリアクティブに切り替え + +【1-4. Gemini AI解析】 +1. GeminiService: + - モデル: gemini-2.5-flash-latest + - リアルタイム実況機能 + - キャッチコピー自動生成 + +【1-5. UI実装】 +1. ホーム画面(4タブ): + - 酒リスト(フル幅カード) + - マップ + - AIソムリエ + - プロフィール + +2. フル幅カード: + - 左: 写真120x120 + - 右: 銘柄名(選択したフォント)、データ + +3. プロフィール画面: + - フォント切り替えスイッチ + - MBTI入力欄(今は空でOK) + - 生年月日入力欄(今は空でOK) + +【1-6. SafeArea対応】 +- すべての画面でSafeAreaを徹底使用 +- Android 15で見切れ・重なりゼロ + +🚀 開始: +ANTIGRAVITY_PROMPT.md の「プロジェクト作成」セクションから開始してください。 +Phase 1-1(プロジェクト初期化)完了後に報告をお願いします。 + +💡 重要な追加仕様: + +【フォント切り替えの実装】 +設定画面に以下を追加: + +```dart +// 設定画面の一部 +Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('フォント設定'), + SegmentedButton( + segments: [ + ButtonSegment(value: 'sans', label: Text('ゴシック')), + ButtonSegment(value: 'serif', label: Text('明朝')), + ], + selected: {currentFont}, + onSelectionChanged: (Set newSelection) { + // UserProfileを更新 + // AppThemeを再構築 + }, + ), + ], +) +``` + +【UserProfileモデルの完全コード】 + +```dart +// lib/models/user_profile.dart +import 'package:hive/hive.dart'; + +part 'user_profile.g.dart'; + +@HiveType(typeId: 1) +class UserProfile extends HiveObject { + @HiveField(0) + String? mbtiType; // "ENFP", "INTJ", etc. + + @HiveField(1) + DateTime? birthDate; // 四柱推命用 + + @HiveField(2) + String fontPreference; // "serif" or "sans" + + @HiveField(3) + DateTime createdAt; + + @HiveField(4) + DateTime? updatedAt; + + UserProfile({ + this.mbtiType, + this.birthDate, + this.fontPreference = 'sans', // デフォルトはゴシック + required this.createdAt, + this.updatedAt, + }); +} +``` + +【Phase 2以降の予告】 +- MBTI × 日本酒相性診断 +- 四柱推命 × 今日の一滴 +- フレーバー・マトリックス +- Instagram用画像生成(診断結果入り) + +これらは Phase 1完了後に実装します。 +今は UserProfile の枠だけ準備してください。 +``` + +--- + +## 📋 実装の流れ + +### Phase 0: 準備(今ここ) +- [x] 新しいフォルダ作成(ponshu_room_lite) +- [x] 設計書をコピー +- [ ] Antigravityに指示を送る + +### Phase 1: MVP(5-6時間) +- [ ] プロジェクト初期化 +- [ ] データモデル(SakeItem + UserProfile) +- [ ] フォント切り替え機能 +- [ ] Gemini解析(リアルタイム実況) +- [ ] フル幅カード +- [ ] SafeArea対応 + +### Phase 2: パーソナライズ(3-4時間) +- [ ] MBTI入力UI +- [ ] MBTI × 日本酒相性診断 +- [ ] 四柱推命 × 今日の一滴 +- [ ] フレーバー・マトリックス + +### Phase 3: 共有機能(2時間) +- [ ] Instagram用画像生成(診断結果入り) +- [ ] キャッチコピー付き共有 + +### Phase 4: 遊び心(3時間) +- [ ] 日本酒・制覇マップ +- [ ] AIソムリエチャット + +**合計: 13-15時間** + +--- + +## 🎨 フォント選択のガイド + +### デフォルト: Noto Sans JP(ゴシック体) +``` +印象: 親しみやすい、読みやすい、診断コンテンツと相性◎ +おすすめな人: MBTIや診断を楽しみたい、カジュアルに記録したい +``` + +### オプション: Noto Serif JP(明朝体) +``` +印象: 高級感、伝統、雑誌のような洗練 +おすすめな人: 日本酒の本格的な雰囲気を大切にしたい +``` + +**いつでも切り替え可能!設定画面からワンタップ。** + +--- + +## 🔐 APIキーの設定 + +Antigravityが `lib/secrets.dart` を作成した後、以下が正しく設定されているか確認してください: + +```dart +// lib/secrets.dart +class Secrets { + static const String geminiApiKey = 'AIzaSyA2BSr16R2k0bHjSYcSUdmLoY8PKwaFts0'; +} +``` + +--- + +## ✅ 完成確認チェックリスト + +### Phase 1 MVP完成の確認 +- [ ] カメラで日本酒を撮影できる +- [ ] Gemini解析で「ラベルを読んでいます...」と表示される +- [ ] キャッチコピーが自動生成される +- [ ] フル幅カード(写真120x120)で表示される +- [ ] フォント切り替えが動作する(ゴシック ⇔ 明朝) +- [ ] Android 15で見切れない +- [ ] UserProfileが保存される + +### Phase 2 パーソナライズ完成の確認 +- [ ] MBTI診断結果を入力できる +- [ ] 「このお酒はあなた(ENFP)にピッタリ!」と表示される +- [ ] フレーバー・マトリックスが表示される + +### 最終完成の確認 +- [ ] すべての機能が動作 +- [ ] 「雑誌のような」デザイン +- [ ] 60fpsの滑らかな動作 +- [ ] 「魔法のような」心地よさ +- [ ] 知人に見せたくなる + +--- + +## 📞 トラブルシューティング + +### もしAntigravityが古いコードを参照しようとしたら + +**即座に指摘してください**: +``` +@Antigravity + +古い ponshu-room フォルダは参照しないでください。 +このフォルダ(ponshu_room_lite)内のドキュメントだけを見てください。 +``` + +### もし余白が大きすぎたら + +``` +@Antigravity + +余白が大きすぎます。以下に修正してください: +- ListView.padding: EdgeInsets.symmetric(horizontal: 8, vertical: 12) +- separatorBuilder: SizedBox(height: 4) +- 写真サイズ: 120x120 +``` + +### もしフォント切り替えが動作しなかったら + +``` +@Antigravity + +フォント切り替えが動作しません。 +UserProfileのfontPreferenceを変更したら、AppThemeが再構築されるようにしてください。 +Riverpodのproviderを使って、リアクティブに更新してください。 +``` + +--- + +## 🎊 準備完了! + +すべての準備が整いました。 + +**次のアクション**: +1. 上記の「Antigravityへの指示」をコピー +2. Antigravityに送信 +3. 実装開始を待つ + +**「ぽんるーむ Lite」の誕生を楽しみにしています!🍶✨** + +--- + +## 💡 将来の拡張(Pro版へ) + +Phase 1-4が完了し、知人にも好評だったら: + +### ぽんるーむ Pro(構想) +- Synology NAS自動バックアップ +- 複数デバイス同期 +- チーム共有機能 +- 高度な統計分析 +- カスタムテーマ +- エクスポート機能強化 + +**まずは Lite を完璧に仕上げましょう!** diff --git a/UI_UX_DECISION_GUIDE.md b/UI_UX_DECISION_GUIDE.md new file mode 100644 index 0000000..9ad3360 --- /dev/null +++ b/UI_UX_DECISION_GUIDE.md @@ -0,0 +1,456 @@ +# 新生ぽんるーむ - UI/UX決定ガイド + +**対象**: プロダクトオーナー(posimai) +**目的**: シンプル・スタイリッシュ・今風なUIを作るための意思決定ガイド + +--- + +## 🎨 今風なUI/UXとは? + +### 2025年のモバイルアプリトレンド + +#### ✅ 採用すべき要素 + +1. **ミニマリズム** + - 余白をたっぷり取る + - 要素を最小限に絞る + - 色数を3-4色に抑える + +2. **マテリアル3デザイン** + - elevation: 0(フラット) + - border: 1px程度の細いライン + - 角丸: 8-12px程度 + +3. **タイポグラフィのメリハリ** + - 見出し: 大きく(20-24px) + - 本文: 適度(13-15px) + - キャプション: 小さく(11-12px) + - フォントは2種類まで + +4. **自然なアニメーション** + - 200-300msの短いアニメーション + - easeInOut curve + - Hero遷移 + +5. **ダークモード非対応でOK** + - ライトモードのみでシンプルに + - 日本酒の写真が映えるのはライトモード + +#### ❌ 避けるべき要素 + +1. グラデーション多用 +2. 派手なドロップシャドウ(elevation: 8以上) +3. 過度な装飾・パターン +4. 5色以上のカラーパレット +5. 遅いアニメーション(500ms以上) + +--- + +## 📋 意思決定チェックリスト + +あなたが決めるべき5つの要素を選択してください。 + +### 1. カラースキーム + +**質問**: posimaiブルー (#376495) 以外のアクセントカラーは? + +#### オプションA: 柔らかいパステル(推奨) +```dart +お気に入り: Color(0xFFE8B4B8) // 淡いピンク +買いたい: Color(0xFFFEF3C7) // 淡い黄色 +``` +**印象**: 優しい、女性受け良い、Instagram映え + +#### オプションB: 渋い和風 +```dart +お気に入り: Color(0xFFB38867) // 茶色 +買いたい: Color(0xFFDAA520) // 金色 +``` +**印象**: 格調高い、男性受け良い、日本酒らしい + +#### オプションC: モノクロ+posimaiブルーのみ +```dart +お気に入り: posimaiBlue +買いたい: Color(0xFF4A4A4A) // グレー +``` +**印象**: 究極にシンプル、プロフェッショナル + +**あなたの選択**: [ A / B / C ] + +--- + +### 2. カードデザイン + +**質問**: リスト表示のカードレイアウトは? + +#### オプションA: 縦型カード(推奨) +``` +┌──────────┐ +│ │ +│ [画像] │ ← 正方形 +│ │ +├──────────┤ +│ 獺祭 │ +│ ★★★★★ │ +└──────────┘ +``` +**メリット**: Instagram的、写真が大きい、スクロールしやすい + +#### オプションB: 横型カード +``` +┌──────┬────────────┐ +│ │ 獺祭 │ +│[画像]│ 旭酒造 │ +│ │ ★★★★★ │ +└──────┴────────────┘ +``` +**メリット**: 情報が見やすい、1画面に多く表示 + +#### オプションC: グリッド表示(2列) +``` +┌────┬────┐ +│[画] │[画] │ +│獺祭 │久保田│ +└────┴────┘ +``` +**メリット**: コレクション感、Pinterest的 + +**あなたの選択**: [ A / B / C ] + +--- + +### 3. アニメーション + +**質問**: 画面遷移のアニメーションは? + +#### オプションA: Hero + Fade(推奨) +```dart +Navigator.push( + context, + PageRouteBuilder( + pageBuilder: (context, animation, secondaryAnimation) => DetailScreen(), + transitionsBuilder: (context, animation, secondaryAnimation, child) { + return FadeTransition( + opacity: animation, + child: child, + ); + }, + ), +); +``` +**印象**: 滑らか、高級感、Instagram的 + +#### オプションB: スライド +```dart +Navigator.push( + context, + CupertinoPageRoute(builder: (context) => DetailScreen()), +); +``` +**印象**: iOS的、シンプル、速い + +#### オプションC: なし +```dart +Navigator.push( + context, + MaterialPageRoute(builder: (context) => DetailScreen()), +); +``` +**印象**: 最速、シンプル、パフォーマンス優先 + +**あなたの選択**: [ A / B / C ] + +--- + +### 4. ボトムナビゲーション + +**質問**: アイコンのスタイルは? + +#### オプションA: Material Icons(推奨) +```dart +Icons.liquor // 酒 +Icons.map // マップ +Icons.smart_toy // AI +Icons.person // マイページ +``` +**印象**: シンプル、認識しやすい、Androidらしい + +#### オプションB: Cupertino Icons +```dart +CupertinoIcons.sparkles // 酒 +CupertinoIcons.map // マップ +CupertinoIcons.chat_bubble // AI +CupertinoIcons.person // マイページ +``` +**印象**: iOS的、おしゃれ、一貫性 + +#### オプションC: カスタムアイコン +``` +🍶 徳利・おちょこ +🗺️ 地図 +🤖 AIロボット +👤 人型 +``` +**印象**: 個性的、日本酒らしい、作成コスト高 + +**あなたの選択**: [ A / B / C ] + +--- + +### 5. FloatingActionButton(カメラボタン) + +**質問**: カメラボタンの動作は? + +#### オプションA: タップでカメラ、長押しでギャラリー(推奨) +```dart +GestureDetector( + onTap: () => launchCamera(), + onLongPress: () => launchGallery(), + child: FloatingActionButton(...), +) +``` +**メリット**: 1ボタンでシンプル、上級者向け + +#### オプションB: タップで選択ダイアログ表示 +```dart +onPressed: () { + showDialog( + child: AlertDialog( + title: Text('写真を選択'), + actions: [ + TextButton(child: Text('カメラ'), onPressed: ...), + TextButton(child: Text('ギャラリー'), onPressed: ...), + ], + ), + ); +} +``` +**メリット**: 分かりやすい、初心者向け + +#### オプションC: 2つのボタン(カメラ・ギャラリー) +```dart +Column( + children: [ + FloatingActionButton(icon: Icons.camera, ...), + SizedBox(height: 8), + FloatingActionButton(icon: Icons.photo_library, ...), + ], +) +``` +**メリット**: 直感的、すぐ選べる + +**あなたの選択**: [ A / B / C ] + +--- + +## 📐 追加の細かい決定事項 + +### 6. 評価表示 + +**質問**: 星の評価表示は? + +#### オプションA: 星アイコン(推奨) +``` +★★★★★ 5.0 +``` + +#### オプションB: 数値のみ +``` +5.0 / 5.0 +``` + +#### オプションC: プログレスバー +``` +━━━━━ 100% +``` + +**あなたの選択**: [ A / B / C ] + +--- + +### 7. 検索バー + +**質問**: 検索バーのデザインは? + +#### オプションA: 角丸ピル型(推奨) +```dart +TextField( + decoration: InputDecoration( + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(30), + borderSide: BorderSide.none, + ), + filled: true, + fillColor: Colors.grey[100], + ), +) +``` + +#### オプションB: 角丸ボックス型 +```dart +TextField( + decoration: InputDecoration( + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), + ), +) +``` + +**あなたの選択**: [ A / B ] + +--- + +### 8. 写真の表示比率 + +**質問**: カード内の写真の比率は? + +#### オプションA: 正方形(1:1)(推奨) +```dart +AspectRatio(aspectRatio: 1) +``` +**メリット**: Instagram的、統一感 + +#### オプションB: 4:3 +```dart +AspectRatio(aspectRatio: 4/3) +``` +**メリット**: 情報量が多い、カメラ標準 + +#### オプションC: 16:9 +```dart +AspectRatio(aspectRatio: 16/9) +``` +**メリット**: 横長、映画的 + +**あなたの選択**: [ A / B / C ] + +--- + +### 9. タグ表示 + +**質問**: タグのデザインは? + +#### オプションA: 角丸チップ(推奨) +```dart +Chip( + label: Text('甘口'), + backgroundColor: Colors.blue[50], + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), +) +``` + +#### オプションB: ボックス型 +```dart +Container( + padding: EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.blue[50], + borderRadius: BorderRadius.circular(4), + ), + child: Text('甘口'), +) +``` + +**あなたの選択**: [ A / B ] + +--- + +### 10. ローディング表示 + +**質問**: AI解析中のローディングは? + +#### オプションA: CircularProgressIndicator(推奨) +```dart +Center( + child: CircularProgressIndicator( + valueColor: AlwaysStoppedAnimation(posimaiBlue), + ), +) +``` + +#### オプションB: LinearProgressIndicator +```dart +LinearProgressIndicator( + valueColor: AlwaysStoppedAnimation(posimaiBlue), +) +``` + +#### オプションC: カスタムアニメーション +``` +🍶 徳利がくるくる回る +``` + +**あなたの選択**: [ A / B / C ] + +--- + +## 🎯 推奨設定(まとめ) + +もし迷ったら、この設定で進めてください: + +```yaml +カラースキーム: オプションA(柔らかいパステル) +カードデザイン: オプションA(縦型カード) +アニメーション: オプションA(Hero + Fade) +ボトムナビゲーション: オプションA(Material Icons) +FABの動作: オプションA(タップでカメラ、長押しでギャラリー) +評価表示: オプションA(星アイコン) +検索バー: オプションA(角丸ピル型) +写真比率: オプションA(正方形 1:1) +タグ表示: オプションA(角丸チップ) +ローディング: オプションA(CircularProgressIndicator) +``` + +**この設定の印象**: Instagram的、洗練、女性受け良、シンプル、今風 + +--- + +## 📱 参考アプリ + +### 同じ方向性のアプリ + +1. **Instagram** - 写真重視、ミニマル +2. **Pinterest** - グリッド表示、コレクション感 +3. **Apple Music** - 角丸カード、余白たっぷり +4. **Spotify** - ダークモード、アルバムアート重視 +5. **Google Keep** - シンプル、カラフルなタグ + +### 避けるべき方向性 + +1. **古いSkeuomorphic** - リアルな質感 +2. **ゴチャゴチャしたUI** - 情報過多 +3. **派手なアニメーション** - ゲーム的 + +--- + +## ✅ 決定シート + +以下をコピーして、あなたの選択を記入してください: + +``` +=== 新生ぽんるーむ UI/UX決定シート === + +1. カラースキーム: [ A / B / C ] +2. カードデザイン: [ A / B / C ] +3. アニメーション: [ A / B / C ] +4. ボトムナビゲーション: [ A / B / C ] +5. FABの動作: [ A / B / C ] +6. 評価表示: [ A / B / C ] +7. 検索バー: [ A / B ] +8. 写真比率: [ A / B / C ] +9. タグ表示: [ A / B ] +10. ローディング: [ A / B / C ] + +その他の要望: + + +署名: posimai +日付: 2025-12-29 +``` + +--- + +**このシートを埋めて、Antigravityに渡してください!** diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..0d29021 --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1,28 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +include: package:flutter_lints/flutter.yaml + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at https://dart.dev/lints. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + # avoid_print: false # Uncomment to disable the `avoid_print` rule + # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/analysis_output.txt b/analysis_output.txt new file mode 100644 index 0000000..de2c52e --- /dev/null +++ b/analysis_output.txt @@ -0,0 +1,96 @@ +Resolving dependencies... +Downloading packages... + _fe_analyzer_shared 67.0.0 (92.0.0 available) + analyzer 6.4.1 (9.0.0 available) + analyzer_plugin 0.11.3 (0.13.11 available) + build 2.4.1 (4.0.3 available) + build_config 1.1.2 (1.2.0 available) + build_resolvers 2.4.2 (3.0.4 available) + build_runner 2.4.13 (2.10.4 available) + build_runner_core 7.3.2 (9.3.2 available) + characters 1.4.0 (1.4.1 available) + custom_lint 0.5.11 (0.8.1 available) + custom_lint_core 0.5.14 (0.8.2 available) + dart_style 2.3.6 (3.1.3 available) + freezed_annotation 2.4.4 (3.1.0 available) + image 4.5.4 (4.7.2 available) + matcher 0.12.17 (0.12.18 available) + material_color_utilities 0.11.1 (0.13.0 available) + package_info_plus 8.3.1 (9.0.0 available) + riverpod_analyzer_utils 1.0.0-dev.1 (1.0.0-dev.8 available) + riverpod_annotation 3.0.0-dev.3 (4.0.0 available) + riverpod_generator 3.0.0-dev.11 (4.0.0+1 available) + rxdart 0.27.7 (0.28.0 available) + shelf_web_socket 2.0.1 (3.0.0 available) + source_gen 1.5.0 (4.1.1 available) + source_helper 1.3.5 (1.3.9 available) + test 1.26.3 (1.28.0 available) + test_api 0.7.7 (0.7.8 available) + test_core 0.6.12 (0.6.14 available) +Got dependencies! +27 packages have newer versions incompatible with dependency constraints. +Try `flutter pub outdated` for more information. +Analyzing ponshu_room_lite... + + info - The import of 'models/schema/display_data.dart' is unnecessary because all of the used elements are also provided by the import of 'models/sake_item.dart' - lib\main.dart:11:8 - unnecessary_import + info - The import of 'models/schema/hidden_specs.dart' is unnecessary because all of the used elements are also provided by the import of 'models/sake_item.dart' - lib\main.dart:12:8 - unnecessary_import + info - The import of 'models/schema/user_data.dart' is unnecessary because all of the used elements are also provided by the import of 'models/sake_item.dart' - lib\main.dart:13:8 - unnecessary_import + info - The import of 'models/schema/gamification.dart' is unnecessary because all of the used elements are also provided by the import of 'models/sake_item.dart' - lib\main.dart:14:8 - unnecessary_import + info - The import of 'models/schema/metadata.dart' is unnecessary because all of the used elements are also provided by the import of 'models/sake_item.dart' - lib\main.dart:15:8 - unnecessary_import +warning - Unused import: 'package:hive_flutter/hive_flutter.dart' - lib\providers\menu_providers.dart:2:8 - unused_import +warning - Unused import: '../models/sake_item.dart' - lib\providers\menu_providers.dart:3:8 - unused_import + info - The private field _capturedImages could be 'final' - lib\screens\camera_screen.dart:59:16 - prefer_final_fields + info - Don't invoke 'print' in production code - lib\screens\camera_screen.dart:133:7 - avoid_print +warning - Unused import: '../widgets/quota_warning_dialog.dart' - lib\screens\home_screen.dart:19:8 - unused_import + info - Don't use 'BuildContext's across async gaps - lib\screens\home_screen.dart:45:34 - use_build_context_synchronously +warning - The value of the local variable 'selectedTag' isn't used - lib\screens\home_screen.dart:54:11 - unused_local_variable + info - 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss - lib\screens\home_screen.dart:145:55 - deprecated_member_use + info - 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss - lib\screens\home_screen.dart:468:54 - deprecated_member_use + info - Unnecessary use of multiple underscores - lib\screens\menu_pricing_screen.dart:76:18 - unnecessary_underscores + info - 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss - lib\screens\menu_pricing_screen.dart:114:38 - deprecated_member_use + info - 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss - lib\screens\menu_pricing_screen.dart:115:39 - deprecated_member_use + info - Unnecessary use of multiple underscores - lib\screens\menu_pricing_screen.dart:160:43 - unnecessary_underscores + info - 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss - lib\screens\menu_pricing_screen.dart:176:47 - deprecated_member_use + info - 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss - lib\screens\menu_pricing_screen.dart:260:36 - deprecated_member_use + info - 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss - lib\screens\menu_pricing_screen.dart:261:34 - deprecated_member_use +warning - Unused import: '../services/pdf_service.dart' - lib\screens\menu_settings_screen.dart:8:8 - unused_import +warning - Unused import: 'package:printing/printing.dart' - lib\screens\menu_settings_screen.dart:12:8 - unused_import + info - Unnecessary use of multiple underscores - lib\screens\menu_settings_screen.dart:157:18 - unnecessary_underscores + info - Unnecessary use of 'toList' in a spread - lib\screens\menu_settings_screen.dart:194:26 - unnecessary_to_list_in_spreads + info - 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss - lib\screens\menu_settings_screen.dart:227:86 - deprecated_member_use + info - 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss - lib\screens\menu_settings_screen.dart:417:48 - deprecated_member_use + info - Don't use 'BuildContext's across async gaps - lib\screens\sake_detail_screen.dart:451:27 - use_build_context_synchronously + info - Unnecessary braces in a string interpolation - lib\screens\sake_detail_screen.dart:668:26 - unnecessary_brace_in_string_interps +warning - The value of the local variable 'price' isn't used - lib\screens\sake_detail_screen.dart:711:17 - unused_local_variable + info - Unnecessary use of 'toList' in a spread - lib\screens\sake_detail_screen.dart:866:31 - unnecessary_to_list_in_spreads + info - Don't use 'BuildContext's across async gaps, guarded by an unrelated 'mounted' check - lib\screens\sake_detail_screen.dart:986:23 - use_build_context_synchronously + info - Don't use 'BuildContext's across async gaps, guarded by an unrelated 'mounted' check - lib\screens\sake_detail_screen.dart:987:30 - use_build_context_synchronously + info - 'activeColor' is deprecated and shouldn't be used. Use activeThumbColor instead. This feature was deprecated after v3.31.0-2.0.pre - lib\screens\soul_screen.dart:128:22 - deprecated_member_use + info - Unnecessary braces in a string interpolation - lib\services\image_compression_service.dart:39:63 - unnecessary_brace_in_string_interps + info - Unnecessary braces in a string interpolation - lib\services\image_compression_service.dart:54:55 - unnecessary_brace_in_string_interps + info - Unnecessary braces in a string interpolation - lib\services\image_compression_service.dart:54:88 - unnecessary_brace_in_string_interps + info - Don't invoke 'print' in production code - lib\services\migration_service.dart:11:5 - avoid_print + info - Don't invoke 'print' in production code - lib\services\migration_service.dart:32:9 - avoid_print + info - Don't invoke 'print' in production code - lib\services\migration_service.dart:51:9 - avoid_print + info - Don't invoke 'print' in production code - lib\services\migration_service.dart:53:9 - avoid_print + info - Don't invoke 'print' in production code - lib\services\migration_service.dart:60:7 - avoid_print + info - Don't invoke 'print' in production code - lib\services\migration_service.dart:80:11 - avoid_print + info - Don't invoke 'print' in production code - lib\services\migration_service.dart:86:7 - avoid_print + info - Don't invoke 'print' in production code - lib\services\migration_service.dart:88:7 - avoid_print + info - Don't invoke 'print' in production code - lib\services\pdf_service.dart:37:14 - avoid_print +warning - The declaration '_getChildAspectRatio' isn't referenced - lib\services\pdf_service.dart:355:17 - unused_element + info - 'scale' is deprecated and shouldn't be used. Use scaleByVector3, scaleByVector4, or scaleByDouble instead - lib\widgets\sake_3d_carousel.dart:82:11 - deprecated_member_use + info - Can't use a relative path to import a library in 'lib' - tool\check_models.dart:3:8 - avoid_relative_lib_imports + info - Don't invoke 'print' in production code - tool\check_models.dart:6:3 - avoid_print + info - Don't invoke 'print' in production code - tool\check_models.dart:10:5 - avoid_print + info - Don't invoke 'print' in production code - tool\check_models.dart:26:7 - avoid_print + info - Don't invoke 'print' in production code - tool\check_models.dart:32:14 - avoid_print + info - Don't invoke 'print' in production code - tool\check_models.dart:33:14 - avoid_print + info - Don't invoke 'print' in production code - tool\check_models.dart:34:14 - avoid_print + info - Don't invoke 'print' in production code - tool\check_models.dart:35:14 - avoid_print + info - Don't invoke 'print' in production code - tool\check_models.dart:39:9 - avoid_print + info - Don't invoke 'print' in production code - tool\check_models.dart:42:7 - avoid_print + info - Don't invoke 'print' in production code - tool\check_models.dart:44:7 - avoid_print + info - Don't invoke 'print' in production code - tool\check_models.dart:48:5 - avoid_print + +60 issues found. (ran in 3.4s) diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000..be3943c --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,14 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java +.cxx/ + +# Remember to never publicly share your keystore. +# See https://flutter.dev/to/reference-keystore +key.properties +**/*.keystore +**/*.jks diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 0000000..9c60b3e --- /dev/null +++ b/android/app/build.gradle.kts @@ -0,0 +1,54 @@ +plugins { + id("com.android.application") + id("kotlin-android") + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id("dev.flutter.flutter-gradle-plugin") + id("com.google.gms.google-services") +} + +android { + namespace = "com.posimai.ponshu_room_lite" + compileSdk = 36 + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_17.toString() + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId = "com.posimai.ponshu_room_lite" + // You can update the following values to match your application needs. + // For more information, see: https://flutter.dev/to/review-gradle-config. + minSdk = 24 + targetSdk = 34 + versionCode = flutter.versionCode + versionName = flutter.versionName + } + + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig = signingConfigs.getByName("debug") + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + } + debug { + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + } + } +} + +flutter { + source = "../.." +} + +dependencies { + // 日本語OCR認識のためのML Kitライブラリ(型番スキャンアプリと同じ設定) + implementation("com.google.mlkit:text-recognition-japanese:16.0.1") +} diff --git a/android/app/google-services.json b/android/app/google-services.json new file mode 100644 index 0000000..b057028 --- /dev/null +++ b/android/app/google-services.json @@ -0,0 +1,29 @@ +{ + "project_info": { + "project_number": "1086358251978", + "project_id": "gen-lang-client-0086450305", + "storage_bucket": "gen-lang-client-0086450305.firebasestorage.app" + }, + "client": [ + { + "client_info": { + "mobilesdk_app_id": "1:1086358251978:android:997279498c984e4bae4733", + "android_client_info": { + "package_name": "com.posimai.ponshu_room_lite" + } + }, + "oauth_client": [], + "api_key": [ + { + "current_key": "AIzaSyDesRm4WlV3Aa31bY4vkJr8MeoKdYH-038" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [] + } + } + } + ], + "configuration_version": "1" +} \ No newline at end of file diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro new file mode 100644 index 0000000..1c55746 --- /dev/null +++ b/android/app/proguard-rules.pro @@ -0,0 +1,31 @@ +# Google ML Kit Text Recognition +-keep class com.google.mlkit.vision.text.** { *; } +-dontwarn com.google.mlkit.vision.text.** + +# Keep all classes referenced by ML Kit +-keep class com.google.android.gms.** { *; } +-dontwarn com.google.android.gms.** + +# ML Kit Japanese OCR model classes - IMPORTANT: do not remove +-keep class com.google.mlkit.vision.text.japanese.** { *; } +-keep class com.google.mlkit.vision.text.chinese.** { *; } +-keep class com.google.mlkit.vision.text.devanagari.** { *; } +-keep class com.google.mlkit.vision.text.korean.** { *; } + +-dontwarn com.google.mlkit.vision.text.chinese.** +-dontwarn com.google.mlkit.vision.text.devanagari.** +-dontwarn com.google.mlkit.vision.text.japanese.** +-dontwarn com.google.mlkit.vision.text.korean.** + +# TensorFlow Lite (ML Kit dependency) +-keep class org.tensorflow.lite.** { *; } +-dontwarn org.tensorflow.lite.** + +# Native libraries +-keepclasseswithmembernames class * { + native ; +} + +# ML Kit internal classes +-keep class com.google.mlkit.common.** { *; } +-keep class com.google.mlkit.** { *; } diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..9de44d0 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/kotlin/com/posimai/ponshu_room_lite/MainActivity.kt b/android/app/src/main/kotlin/com/posimai/ponshu_room_lite/MainActivity.kt new file mode 100644 index 0000000..f6685fa --- /dev/null +++ b/android/app/src/main/kotlin/com/posimai/ponshu_room_lite/MainActivity.kt @@ -0,0 +1,5 @@ +package com.posimai.ponshu_room_lite + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity : FlutterActivity() diff --git a/android/app/src/main/res/drawable-v21/launch_background.xml b/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..f74085f --- /dev/null +++ b/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/android/app/src/main/res/drawable/launch_background.xml b/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..304732f --- /dev/null +++ b/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..db77bb4 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-hdpi/launcher_icon.png b/android/app/src/main/res/mipmap-hdpi/launcher_icon.png new file mode 100644 index 0000000..226354b Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/launcher_icon.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..17987b7 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/launcher_icon.png b/android/app/src/main/res/mipmap-mdpi/launcher_icon.png new file mode 100644 index 0000000..aba7a0f Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/launcher_icon.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..09d4391 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/launcher_icon.png b/android/app/src/main/res/mipmap-xhdpi/launcher_icon.png new file mode 100644 index 0000000..8eafecb Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/launcher_icon.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..d5f1c8d Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/launcher_icon.png b/android/app/src/main/res/mipmap-xxhdpi/launcher_icon.png new file mode 100644 index 0000000..dbef8eb Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/launcher_icon.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..4d6372e Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/launcher_icon.png b/android/app/src/main/res/mipmap-xxxhdpi/launcher_icon.png new file mode 100644 index 0000000..8a02c65 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/launcher_icon.png differ diff --git a/android/app/src/main/res/values-night/styles.xml b/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..06952be --- /dev/null +++ b/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..cb1ef88 --- /dev/null +++ b/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/android/app/src/profile/AndroidManifest.xml b/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 0000000..dbee657 --- /dev/null +++ b/android/build.gradle.kts @@ -0,0 +1,24 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +val newBuildDir: Directory = + rootProject.layout.buildDirectory + .dir("../../build") + .get() +rootProject.layout.buildDirectory.value(newBuildDir) + +subprojects { + val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) + project.layout.buildDirectory.value(newSubprojectBuildDir) +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..fbee1d8 --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,2 @@ +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..e4ef43f --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts new file mode 100644 index 0000000..32c47f6 --- /dev/null +++ b/android/settings.gradle.kts @@ -0,0 +1,27 @@ +pluginManagement { + val flutterSdkPath = + run { + val properties = java.util.Properties() + file("local.properties").inputStream().use { properties.load(it) } + val flutterSdkPath = properties.getProperty("flutter.sdk") + require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } + flutterSdkPath + } + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id("dev.flutter.flutter-plugin-loader") version "1.0.0" + id("com.android.application") version "8.11.1" apply false + id("org.jetbrains.kotlin.android") version "2.2.20" apply false + id("com.google.gms.google-services") version "4.4.0" apply false +} + +include(":app") diff --git a/assets/fonts/NotoSansJP-Regular.ttf b/assets/fonts/NotoSansJP-Regular.ttf new file mode 100644 index 0000000..bb71b63 Binary files /dev/null and b/assets/fonts/NotoSansJP-Regular.ttf differ diff --git a/assets/images/app_icon.png b/assets/images/app_icon.png new file mode 100644 index 0000000..f3a3a5b Binary files /dev/null and b/assets/images/app_icon.png differ diff --git a/assets/images/set_placeholder.png b/assets/images/set_placeholder.png new file mode 100644 index 0000000..dd41938 Binary files /dev/null and b/assets/images/set_placeholder.png differ diff --git a/ios/.gitignore b/ios/.gitignore new file mode 100644 index 0000000..7a7f987 --- /dev/null +++ b/ios/.gitignore @@ -0,0 +1,34 @@ +**/dgph +*.mode1v3 +*.mode2v3 +*.moved-aside +*.pbxuser +*.perspectivev3 +**/*sync/ +.sconsign.dblite +.tags* +**/.vagrant/ +**/DerivedData/ +Icon? +**/Pods/ +**/.symlinks/ +profile +xcuserdata +**/.generated/ +Flutter/App.framework +Flutter/Flutter.framework +Flutter/Flutter.podspec +Flutter/Generated.xcconfig +Flutter/ephemeral/ +Flutter/app.flx +Flutter/app.zip +Flutter/flutter_assets/ +Flutter/flutter_export_environment.sh +ServiceDefinitions.json +Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!default.mode1v3 +!default.mode2v3 +!default.pbxuser +!default.perspectivev3 diff --git a/ios/Flutter/AppFrameworkInfo.plist b/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 0000000..1dc6cf7 --- /dev/null +++ b/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,26 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + MinimumOSVersion + 13.0 + + diff --git a/ios/Flutter/Debug.xcconfig b/ios/Flutter/Debug.xcconfig new file mode 100644 index 0000000..592ceee --- /dev/null +++ b/ios/Flutter/Debug.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/ios/Flutter/Release.xcconfig b/ios/Flutter/Release.xcconfig new file mode 100644 index 0000000..592ceee --- /dev/null +++ b/ios/Flutter/Release.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..a3f91f4 --- /dev/null +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,616 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXBuildFile section */ + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 97C146ED1CF9000F007C117D; + remoteInfo = Runner; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 9705A1C41CF9048500538489 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; + 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; + 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 97C146EB1CF9000F007C117D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C8082294A63A400263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C807B294A618700263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 9740EEB11CF90186004384FC /* Flutter */ = { + isa = PBXGroup; + children = ( + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 9740EEB31CF90195004384FC /* Generated.xcconfig */, + ); + name = Flutter; + sourceTree = ""; + }; + 97C146E51CF9000F007C117D = { + isa = PBXGroup; + children = ( + 9740EEB11CF90186004384FC /* Flutter */, + 97C146F01CF9000F007C117D /* Runner */, + 97C146EF1CF9000F007C117D /* Products */, + 331C8082294A63A400263BE5 /* RunnerTests */, + ); + sourceTree = ""; + }; + 97C146EF1CF9000F007C117D /* Products */ = { + isa = PBXGroup; + children = ( + 97C146EE1CF9000F007C117D /* Runner.app */, + 331C8081294A63A400263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 97C146F01CF9000F007C117D /* Runner */ = { + isa = PBXGroup; + children = ( + 97C146FA1CF9000F007C117D /* Main.storyboard */, + 97C146FD1CF9000F007C117D /* Assets.xcassets */, + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, + 97C147021CF9000F007C117D /* Info.plist */, + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + ); + path = Runner; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C8080294A63A400263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 331C807D294A63A400263BE5 /* Sources */, + 331C807F294A63A400263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C8086294A63A400263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 97C146ED1CF9000F007C117D /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 9740EEB61CF901F6004384FC /* Run Script */, + 97C146EA1CF9000F007C117D /* Sources */, + 97C146EB1CF9000F007C117D /* Frameworks */, + 97C146EC1CF9000F007C117D /* Resources */, + 9705A1C41CF9048500538489 /* Embed Frameworks */, + 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Runner; + productName = Runner; + productReference = 97C146EE1CF9000F007C117D /* Runner.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 97C146E61CF9000F007C117D /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C8080294A63A400263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 97C146ED1CF9000F007C117D; + }; + 97C146ED1CF9000F007C117D = { + CreatedOnToolsVersion = 7.3.1; + LastSwiftMigration = 1100; + }; + }; + }; + buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 97C146E51CF9000F007C117D; + productRefGroup = 97C146EF1CF9000F007C117D /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 97C146ED1CF9000F007C117D /* Runner */, + 331C8080294A63A400263BE5 /* RunnerTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C807F294A63A400263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EC1CF9000F007C117D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", + ); + name = "Thin Binary"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; + }; + 9740EEB61CF901F6004384FC /* Run Script */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Run Script"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C807D294A63A400263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EA1CF9000F007C117D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C8086294A63A400263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 97C146ED1CF9000F007C117D /* Runner */; + targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 97C146FA1CF9000F007C117D /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C146FB1CF9000F007C117D /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C147001CF9000F007C117D /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 249021D3217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Profile; + }; + 249021D4217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.posimai.ponshuRoomLite; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Profile; + }; + 331C8088294A63A400263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.posimai.ponshuRoomLite.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Debug; + }; + 331C8089294A63A400263BE5 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.posimai.ponshuRoomLite.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Release; + }; + 331C808A294A63A400263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.posimai.ponshuRoomLite.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Profile; + }; + 97C147031CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 97C147041CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 97C147061CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.posimai.ponshuRoomLite; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + 97C147071CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.posimai.ponshuRoomLite; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C8088294A63A400263BE5 /* Debug */, + 331C8089294A63A400263BE5 /* Release */, + 331C808A294A63A400263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147031CF9000F007C117D /* Debug */, + 97C147041CF9000F007C117D /* Release */, + 249021D3217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147061CF9000F007C117D /* Debug */, + 97C147071CF9000F007C117D /* Release */, + 249021D4217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 97C146E61CF9000F007C117D /* Project object */; +} diff --git a/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..e3773d4 --- /dev/null +++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..1d526a1 --- /dev/null +++ b/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift new file mode 100644 index 0000000..6266644 --- /dev/null +++ b/ios/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import Flutter +import UIKit + +@main +@objc class AppDelegate: FlutterAppDelegate { + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + GeneratedPluginRegistrant.register(with: self) + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } +} diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..d36b1fa --- /dev/null +++ b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,122 @@ +{ + "images" : [ + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@3x.png", + "scale" : "3x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@3x.png", + "scale" : "3x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@3x.png", + "scale" : "3x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@2x.png", + "scale" : "2x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@3x.png", + "scale" : "3x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@1x.png", + "scale" : "1x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@1x.png", + "scale" : "1x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@1x.png", + "scale" : "1x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@2x.png", + "scale" : "2x" + }, + { + "size" : "83.5x83.5", + "idiom" : "ipad", + "filename" : "Icon-App-83.5x83.5@2x.png", + "scale" : "2x" + }, + { + "size" : "1024x1024", + "idiom" : "ios-marketing", + "filename" : "Icon-App-1024x1024@1x.png", + "scale" : "1x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 0000000..070d0ef Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png new file mode 100644 index 0000000..19ba576 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 0000000..7f4ab33 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 0000000..53762c3 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png new file mode 100644 index 0000000..b8da054 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 0000000..fd102bf Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png new file mode 100644 index 0000000..d363105 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png new file mode 100644 index 0000000..7f4ab33 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 0000000..244cf9d Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png new file mode 100644 index 0000000..1b965c4 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png new file mode 100644 index 0000000..5e69f48 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png new file mode 100644 index 0000000..e06862f Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png new file mode 100644 index 0000000..a1f8fae Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png new file mode 100644 index 0000000..7066a80 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 0000000..1b965c4 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 0000000..a47d67c Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png new file mode 100644 index 0000000..226354b Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png new file mode 100644 index 0000000..dbef8eb Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png new file mode 100644 index 0000000..f622a8f Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png new file mode 100644 index 0000000..a09f98d Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png new file mode 100644 index 0000000..c4db988 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json new file mode 100644 index 0000000..0bedcf2 --- /dev/null +++ b/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "LaunchImage.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md new file mode 100644 index 0000000..89c2725 --- /dev/null +++ b/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md @@ -0,0 +1,5 @@ +# Launch Screen Assets + +You can customize the launch screen with your own desired assets by replacing the image files in this directory. + +You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. \ No newline at end of file diff --git a/ios/Runner/Base.lproj/LaunchScreen.storyboard b/ios/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..f2e259c --- /dev/null +++ b/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner/Base.lproj/Main.storyboard b/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 0000000..f3c2851 --- /dev/null +++ b/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist new file mode 100644 index 0000000..66f41b9 --- /dev/null +++ b/ios/Runner/Info.plist @@ -0,0 +1,53 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Ponshu Room Lite + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + ponshu_room_lite + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + CADisableMinimumFrameDurationOnPhone + + NSCameraUsageDescription + 日本酒のラベルを撮影するためにカメラを使用します + NSPhotoLibraryAddUsageDescription + 撮影した日本酒の写真をギャラリーに保存します + UIApplicationSupportsIndirectInputEvents + + + diff --git a/ios/Runner/Runner-Bridging-Header.h b/ios/Runner/Runner-Bridging-Header.h new file mode 100644 index 0000000..308a2a5 --- /dev/null +++ b/ios/Runner/Runner-Bridging-Header.h @@ -0,0 +1 @@ +#import "GeneratedPluginRegistrant.h" diff --git a/ios/RunnerTests/RunnerTests.swift b/ios/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..86a7c3b --- /dev/null +++ b/ios/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Flutter +import UIKit +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/keytool_output.txt b/keytool_output.txt new file mode 100644 index 0000000..921d70a Binary files /dev/null and b/keytool_output.txt differ diff --git a/lib/main.dart b/lib/main.dart new file mode 100644 index 0000000..6b644a5 --- /dev/null +++ b/lib/main.dart @@ -0,0 +1,85 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; // Localization +import 'package:hive_flutter/hive_flutter.dart'; +import 'models/sake_item.dart'; +import 'models/user_profile.dart'; +import 'models/menu_settings.dart'; +import 'providers/theme_provider.dart'; +import 'screens/main_screen.dart'; + +import 'models/schema/display_data.dart'; +import 'models/schema/hidden_specs.dart'; +import 'models/schema/user_data.dart'; +import 'models/schema/gamification.dart'; +import 'models/schema/metadata.dart'; +import 'models/schema/item_type.dart'; +import 'services/migration_service.dart'; + +void main() async { + WidgetsFlutterBinding.ensureInitialized(); + + // Initialize Hive + await Hive.initFlutter(); + + // Register Adapters + Hive.registerAdapter(SakeItemAdapter()); + Hive.registerAdapter(UserProfileAdapter()); + Hive.registerAdapter(MenuSettingsAdapter()); + // Phase 0 New Adapters + Hive.registerAdapter(DisplayDataAdapter()); + Hive.registerAdapter(HiddenSpecsAdapter()); + Hive.registerAdapter(UserDataAdapter()); + Hive.registerAdapter(GamificationAdapter()); + Hive.registerAdapter(MetadataAdapter()); + Hive.registerAdapter(ItemTypeAdapter()); + + // Run Phase 0 Migration (Backup & Convert) + await MigrationService.runMigration(); + + // Open Boxes + final userProfileBox = await Hive.openBox('user_profile'); + await Hive.openBox('sake_items'); // Already opened by Migration, but safe to call again + await Hive.openBox('settings'); // Generic box for app settings (sort order) + await Hive.openBox('menu_settings'); // Menu display settings + + runApp( + ProviderScope( + overrides: [ + userProfileBoxProvider.overrideWithValue(userProfileBox), + ], + child: const MyApp(), + ), + ); +} + +class MyApp extends ConsumerWidget { + const MyApp({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final lightTheme = ref.watch(lightThemeProvider); + final darkTheme = ref.watch(darkThemeProvider); + final themeMode = ref.watch(themeModeProvider); + + return MaterialApp( + debugShowCheckedModeBanner: false, + title: 'Ponshu Room Lite', + theme: lightTheme, + darkTheme: darkTheme, + themeMode: themeMode, + + // Localization Fix for DatePicker & Menu + localizationsDelegates: const [ + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + supportedLocales: const [ + Locale('ja', 'JP'), + ], + + home: const MainScreen(), + ); + } +} diff --git a/lib/models/maps/japan_map_data.dart b/lib/models/maps/japan_map_data.dart new file mode 100644 index 0000000..2bdaf50 --- /dev/null +++ b/lib/models/maps/japan_map_data.dart @@ -0,0 +1,127 @@ +class JapanMapData { + // 0: Empty/Ocean + // 1-47: Prefecture IDs (JIS Code) + + // High-Resolution 8-bit Map (26 cols x 32 rows) + // 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 + [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 + + // 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 + + // 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 + + // 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 + + // 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 + + // 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], + [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], + [47, 47, 47, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], // Okinawa + [0, 47, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], // Okinawa + ]; + + static const Map prefectureNames = { + 1: '北海道', + 2: '青森県', + 3: '岩手県', + 4: '宮城県', + 5: '秋田県', + 6: '山形県', + 7: '福島県', + 8: '茨城県', + 9: '栃木県', + 10: '群馬県', + 11: '埼玉県', + 12: '千葉県', + 13: '東京都', + 14: '神奈川県', + 15: '新潟県', + 16: '富山県', + 17: '石川県', + 18: '福井県', + 19: '山梨県', + 20: '長野県', + 21: '岐阜県', + 22: '静岡県', + 23: '愛知県', + 24: '三重県', + 25: '滋賀県', + 26: '京都府', + 27: '大阪府', + 28: '兵庫県', + 29: '奈良県', + 30: '和歌山県', + 31: '鳥取県', + 32: '島根県', + 33: '岡山県', + 34: '広島県', + 35: '山口県', + 36: '徳島県', + 37: '香川県', + 38: '愛媛県', + 39: '高知県', + 40: '福岡県', + 41: '佐賀県', + 42: '長崎県', + 43: '熊本県', + 44: '大分県', + 45: '宮崎県', + 46: '鹿児島県', + 47: '沖縄県', + }; + + // Region grouping for visual rhythm (coloring) + // 1: Hokkaido, 2: Tohoku, 3: Kanto, 4: Chubu, 5: Kansai, 6: Chugoku, 7: Shikoku, 8: Kyushu/Okinawa + static const Map regionNames = { + 1: '北海道', + 2: '東北', + 3: '関東', + 4: '中部', + 5: '近畿', + 6: '中国', + 7: '四国', + 8: '九州・沖縄', + }; + + static int getRegionId(int prefId) { + if (prefId == 1) return 1; + if (prefId >= 2 && prefId <= 7) return 2; + if (prefId >= 8 && prefId <= 14) return 3; + if (prefId >= 15 && prefId <= 23) return 4; + if (prefId >= 24 && prefId <= 30) return 5; + if (prefId >= 31 && prefId <= 35) return 6; + if (prefId >= 36 && prefId <= 39) return 7; + if (prefId >= 40 && prefId <= 47) return 8; + return 0; + } +} diff --git a/lib/models/menu_settings.dart b/lib/models/menu_settings.dart new file mode 100644 index 0000000..3bd5c07 --- /dev/null +++ b/lib/models/menu_settings.dart @@ -0,0 +1,69 @@ +import 'package:hive/hive.dart'; + +part 'menu_settings.g.dart'; + +@HiveType(typeId: 3) +class MenuSettings extends HiveObject { + @HiveField(0) + String title; + + @HiveField(1) + bool includePhoto; + + @HiveField(2) + bool includePoem; + + @HiveField(3) + bool includeChart; + + @HiveField(4) + bool includePrice; + + @HiveField(5) + bool includeDate; + + @HiveField(8) + bool? includeQr; // Nullable for compatibility + + @HiveField(6) + String pdfSize; // 'a4', 'a5', 'b5' + + @HiveField(7) + bool isMonochrome; + + MenuSettings({ + this.title = '', + this.includePhoto = true, + this.includePoem = true, + this.includeChart = true, + this.includePrice = true, + this.includeDate = true, + this.includeQr = false, + this.pdfSize = 'a4', + this.isMonochrome = false, + }); + + MenuSettings copyWith({ + String? title, + bool? includePhoto, + bool? includePoem, + bool? includeChart, + bool? includePrice, + bool? includeDate, + bool? includeQr, + String? pdfSize, + bool? isMonochrome, + }) { + return MenuSettings( + title: title ?? this.title, + includePhoto: includePhoto ?? this.includePhoto, + includePoem: includePoem ?? this.includePoem, + includeChart: includeChart ?? this.includeChart, + includePrice: includePrice ?? this.includePrice, + includeDate: includeDate ?? this.includeDate, + includeQr: includeQr ?? this.includeQr, + pdfSize: pdfSize ?? this.pdfSize, + isMonochrome: isMonochrome ?? this.isMonochrome, + ); + } +} diff --git a/lib/models/menu_settings.g.dart b/lib/models/menu_settings.g.dart new file mode 100644 index 0000000..093a9ae --- /dev/null +++ b/lib/models/menu_settings.g.dart @@ -0,0 +1,65 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'menu_settings.dart'; + +// ************************************************************************** +// TypeAdapterGenerator +// ************************************************************************** + +class MenuSettingsAdapter extends TypeAdapter { + @override + final int typeId = 3; + + @override + MenuSettings read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return MenuSettings( + title: fields[0] as String, + includePhoto: fields[1] as bool, + includePoem: fields[2] as bool, + includeChart: fields[3] as bool, + includePrice: fields[4] as bool, + includeDate: fields[5] as bool, + includeQr: fields[8] as bool?, + pdfSize: fields[6] as String, + isMonochrome: fields[7] as bool, + ); + } + + @override + void write(BinaryWriter writer, MenuSettings obj) { + writer + ..writeByte(9) + ..writeByte(0) + ..write(obj.title) + ..writeByte(1) + ..write(obj.includePhoto) + ..writeByte(2) + ..write(obj.includePoem) + ..writeByte(3) + ..write(obj.includeChart) + ..writeByte(4) + ..write(obj.includePrice) + ..writeByte(5) + ..write(obj.includeDate) + ..writeByte(8) + ..write(obj.includeQr) + ..writeByte(6) + ..write(obj.pdfSize) + ..writeByte(7) + ..write(obj.isMonochrome); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is MenuSettingsAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} diff --git a/lib/models/sake_item.dart b/lib/models/sake_item.dart new file mode 100644 index 0000000..50668c0 --- /dev/null +++ b/lib/models/sake_item.dart @@ -0,0 +1,282 @@ +import 'package:hive/hive.dart'; +import 'schema/display_data.dart'; +import 'schema/hidden_specs.dart'; +import 'schema/user_data.dart'; +import 'schema/gamification.dart'; +import 'schema/metadata.dart'; +import 'schema/item_type.dart'; + +export 'schema/display_data.dart'; +export 'schema/hidden_specs.dart'; +export 'schema/user_data.dart'; +export 'schema/gamification.dart'; +export 'schema/metadata.dart'; +export 'schema/item_type.dart'; + +part 'sake_item.g.dart'; + +@HiveType(typeId: 0) +class SakeItem extends HiveObject { + // --- Root Identity --- + @HiveField(0) + final String id; + + // --- New Hierarchical Data (Fields 20+) --- + @HiveField(20) + DisplayData? _displayData; + + @HiveField(21) + HiddenSpecs? _hiddenSpecs; + + @HiveField(22) + UserData? _userData; + + @HiveField(23) + Gamification? _gamification; + + @HiveField(24) + Metadata? _metadata; + + @HiveField(25) + ItemType? _itemType; + + // --- Legacy Fields (Deprecated: Kept for Migration) --- + @HiveField(1) + final String? legacyName; + + @HiveField(2) + final String? legacyBrand; + + @HiveField(3) + final String? legacyPrefecture; + + @HiveField(4) + final String? legacyDescription; + + @HiveField(5) + final String? legacyCatchCopy; + + @HiveField(6) + final List? legacyImagePaths; + + @HiveField(7) + final double? legacySweetnessScore; + + @HiveField(8) + final double? legacyBodyScore; + + @HiveField(9) + final DateTime? legacyCreatedAt; + + @HiveField(10) + final int? legacyConfidenceScore; + + @HiveField(11) + final List? legacyFlavorTags; + + @HiveField(12) + final bool? legacyIsFavorite; + + @HiveField(13) + final Map? legacyTasteStats; + + @HiveField(14) + final bool? legacyIsUserEdited; + + @HiveField(15) + final int? legacyCostPrice; + + @HiveField(16) + final int? legacyManualPrice; + + @HiveField(17) + final double? legacyMarkup; + + @HiveField(18) + final Map? legacyPriceVariants; + + SakeItem({ + required this.id, + DisplayData? displayData, + HiddenSpecs? hiddenSpecs, + UserData? userData, + Gamification? gamification, + Metadata? metadata, + ItemType? itemType, + // Legacy params for migration compatibility (optional) + this.legacyName, + this.legacyBrand, + this.legacyPrefecture, + this.legacyDescription, + this.legacyCatchCopy, + this.legacyImagePaths, + this.legacySweetnessScore, + this.legacyBodyScore, + this.legacyCreatedAt, + this.legacyConfidenceScore, + this.legacyFlavorTags, + this.legacyIsFavorite, + this.legacyTasteStats, + this.legacyIsUserEdited, + this.legacyCostPrice, + this.legacyManualPrice, + this.legacyMarkup, + this.legacyPriceVariants, + }) { + _displayData = displayData; + _hiddenSpecs = hiddenSpecs; + _userData = userData; + _gamification = gamification; + _metadata = metadata; + _itemType = itemType; + } + + // --- Smart Getters (Auto-Migration / Fallback) --- + DisplayData get displayData { + if (_displayData != null) return _displayData!; + // Fallback: Construct from Legacy + return DisplayData( + name: legacyName ?? 'Unknown', + brewery: legacyBrand ?? 'Unknown', + prefecture: legacyPrefecture ?? 'Unknown', + catchCopy: legacyCatchCopy, + imagePaths: legacyImagePaths ?? [], + rating: null, // Legacy didn't have rating explicit in display + ); + } + + // Allow setting for UI updates + set displayData(DisplayData val) { + _displayData = val; + save(); // Auto-save on set? Or just update memory. HiveObject usually requires save(). + // Better to just update memory here. + } + + HiddenSpecs get hiddenSpecs { + if (_hiddenSpecs != null) return _hiddenSpecs!; + return HiddenSpecs( + description: legacyDescription, + tasteStats: legacyTasteStats ?? {}, + flavorTags: legacyFlavorTags ?? [], + sweetnessScore: legacySweetnessScore, + bodyScore: legacyBodyScore, + ); + } + + UserData get userData { + if (_userData != null) return _userData!; + return UserData( + isFavorite: legacyIsFavorite ?? false, + isUserEdited: legacyIsUserEdited ?? false, + price: legacyManualPrice, + costPrice: legacyCostPrice, + markup: legacyMarkup ?? 3.0, + priceVariants: legacyPriceVariants, + ); + } + + Gamification get gamification { + if (_gamification != null) return _gamification!; + return Gamification( + ponPoints: 0, // Legacy didn't track + ); + } + + Metadata get metadata { + if (_metadata != null) return _metadata!; + return Metadata( + createdAt: legacyCreatedAt ?? DateTime.now(), + aiConfidence: legacyConfidenceScore, + ); + } + + ItemType get itemType { + // Default to 'sake' for existing data + return _itemType ?? ItemType.sake; + } + + set itemType(ItemType val) { + _itemType = val; + } + + // --- Migration Method --- + // Returns true if migration was performed (i.e., was legacy) + bool ensureMigrated() { + if (_displayData != null) return false; // Already new structure + + // Create New Objects from Legacy Fields + _displayData = displayData; // Uses getter logic + _hiddenSpecs = hiddenSpecs; + _userData = userData; + _gamification = gamification; + _metadata = metadata; + + return true; + } + + // --- CopyWith for Immutable Updates (Backend Compatible) --- + SakeItem copyWith({ + String? name, + String? brand, // Maps to Brewery + String? prefecture, + String? description, + String? catchCopy, + List? imagePaths, + double? sweetnessScore, + double? bodyScore, + int? confidenceScore, // Maps to aiConfidence + List? flavorTags, + bool? isFavorite, + Map? tasteStats, + bool? isUserEdited, + String? memo, + int? costPrice, + int? manualPrice, // Maps to price + double? markup, + Map? priceVariants, + ItemType? itemType, + }) { + // Ensure we have current data structures + final currentDisplay = displayData; + final currentHidden = hiddenSpecs; + final currentUser = userData; + final currentMeta = metadata; + final currentGame = gamification; + + return SakeItem( + id: id, + displayData: currentDisplay.copyWith( + name: name, + brewery: brand, + prefecture: prefecture, + catchCopy: catchCopy, + imagePaths: imagePaths, + ), + hiddenSpecs: currentHidden.copyWith( + description: description, + tasteStats: tasteStats, + flavorTags: flavorTags, + sweetnessScore: sweetnessScore, + bodyScore: bodyScore, + ), + userData: currentUser.copyWith( + isFavorite: isFavorite, + isUserEdited: isUserEdited, + memo: memo, + price: manualPrice, + costPrice: costPrice, + markup: markup, + priceVariants: priceVariants, + ), + gamification: currentGame, + metadata: currentMeta.copyWith( + aiConfidence: confidenceScore, + ), + itemType: itemType ?? this.itemType, + ); + } + // Compact JSON for QR ecosystem + String toQrJson() { + return '{"id":"$id","n":"${displayData.name}","b":"${displayData.brewery}","p":"${displayData.prefecture ?? ""}","s":${hiddenSpecs.sweetnessScore ?? 0},"y":${hiddenSpecs.bodyScore ?? 0},"a":${hiddenSpecs.alcoholContent ?? 0}}'; + } +} diff --git a/lib/models/sake_item.g.dart b/lib/models/sake_item.g.dart new file mode 100644 index 0000000..823fd25 --- /dev/null +++ b/lib/models/sake_item.g.dart @@ -0,0 +1,113 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'sake_item.dart'; + +// ************************************************************************** +// TypeAdapterGenerator +// ************************************************************************** + +class SakeItemAdapter extends TypeAdapter { + @override + final int typeId = 0; + + @override + SakeItem read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return SakeItem( + id: fields[0] as String, + legacyName: fields[1] as String?, + legacyBrand: fields[2] as String?, + legacyPrefecture: fields[3] as String?, + legacyDescription: fields[4] as String?, + legacyCatchCopy: fields[5] as String?, + legacyImagePaths: (fields[6] as List?)?.cast(), + legacySweetnessScore: fields[7] as double?, + legacyBodyScore: fields[8] as double?, + legacyCreatedAt: fields[9] as DateTime?, + legacyConfidenceScore: fields[10] as int?, + legacyFlavorTags: (fields[11] as List?)?.cast(), + legacyIsFavorite: fields[12] as bool?, + legacyTasteStats: (fields[13] as Map?)?.cast(), + legacyIsUserEdited: fields[14] as bool?, + legacyCostPrice: fields[15] as int?, + legacyManualPrice: fields[16] as int?, + legacyMarkup: fields[17] as double?, + legacyPriceVariants: (fields[18] as Map?)?.cast(), + ) + .._displayData = fields[20] as DisplayData? + .._hiddenSpecs = fields[21] as HiddenSpecs? + .._userData = fields[22] as UserData? + .._gamification = fields[23] as Gamification? + .._metadata = fields[24] as Metadata? + .._itemType = fields[25] as ItemType?; + } + + @override + void write(BinaryWriter writer, SakeItem obj) { + writer + ..writeByte(25) + ..writeByte(0) + ..write(obj.id) + ..writeByte(20) + ..write(obj._displayData) + ..writeByte(21) + ..write(obj._hiddenSpecs) + ..writeByte(22) + ..write(obj._userData) + ..writeByte(23) + ..write(obj._gamification) + ..writeByte(24) + ..write(obj._metadata) + ..writeByte(25) + ..write(obj._itemType) + ..writeByte(1) + ..write(obj.legacyName) + ..writeByte(2) + ..write(obj.legacyBrand) + ..writeByte(3) + ..write(obj.legacyPrefecture) + ..writeByte(4) + ..write(obj.legacyDescription) + ..writeByte(5) + ..write(obj.legacyCatchCopy) + ..writeByte(6) + ..write(obj.legacyImagePaths) + ..writeByte(7) + ..write(obj.legacySweetnessScore) + ..writeByte(8) + ..write(obj.legacyBodyScore) + ..writeByte(9) + ..write(obj.legacyCreatedAt) + ..writeByte(10) + ..write(obj.legacyConfidenceScore) + ..writeByte(11) + ..write(obj.legacyFlavorTags) + ..writeByte(12) + ..write(obj.legacyIsFavorite) + ..writeByte(13) + ..write(obj.legacyTasteStats) + ..writeByte(14) + ..write(obj.legacyIsUserEdited) + ..writeByte(15) + ..write(obj.legacyCostPrice) + ..writeByte(16) + ..write(obj.legacyManualPrice) + ..writeByte(17) + ..write(obj.legacyMarkup) + ..writeByte(18) + ..write(obj.legacyPriceVariants); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is SakeItemAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} diff --git a/lib/models/schema/display_data.dart b/lib/models/schema/display_data.dart new file mode 100644 index 0000000..c5a96c1 --- /dev/null +++ b/lib/models/schema/display_data.dart @@ -0,0 +1,51 @@ +import 'package:hive/hive.dart'; + +part 'display_data.g.dart'; + +@HiveType(typeId: 11) +class DisplayData extends HiveObject { + @HiveField(0) + final String name; + + @HiveField(1) + final String brewery; + + @HiveField(2) + final String prefecture; + + @HiveField(3) + final String? catchCopy; + + @HiveField(4) + final List imagePaths; + + @HiveField(5) + final double? rating; + + DisplayData({ + required this.name, + required this.brewery, + required this.prefecture, + this.catchCopy, + required this.imagePaths, + this.rating, + }); + + DisplayData copyWith({ + String? name, + String? brewery, + String? prefecture, + String? catchCopy, + List? imagePaths, + double? rating, + }) { + return DisplayData( + name: name ?? this.name, + brewery: brewery ?? this.brewery, + prefecture: prefecture ?? this.prefecture, + catchCopy: catchCopy ?? this.catchCopy, + imagePaths: imagePaths ?? this.imagePaths, + rating: rating ?? this.rating, + ); + } +} diff --git a/lib/models/schema/display_data.g.dart b/lib/models/schema/display_data.g.dart new file mode 100644 index 0000000..de2a7fd --- /dev/null +++ b/lib/models/schema/display_data.g.dart @@ -0,0 +1,56 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'display_data.dart'; + +// ************************************************************************** +// TypeAdapterGenerator +// ************************************************************************** + +class DisplayDataAdapter extends TypeAdapter { + @override + final int typeId = 11; + + @override + DisplayData read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return DisplayData( + name: fields[0] as String, + brewery: fields[1] as String, + prefecture: fields[2] as String, + catchCopy: fields[3] as String?, + imagePaths: (fields[4] as List).cast(), + rating: fields[5] as double?, + ); + } + + @override + void write(BinaryWriter writer, DisplayData obj) { + writer + ..writeByte(6) + ..writeByte(0) + ..write(obj.name) + ..writeByte(1) + ..write(obj.brewery) + ..writeByte(2) + ..write(obj.prefecture) + ..writeByte(3) + ..write(obj.catchCopy) + ..writeByte(4) + ..write(obj.imagePaths) + ..writeByte(5) + ..write(obj.rating); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is DisplayDataAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} diff --git a/lib/models/schema/gamification.dart b/lib/models/schema/gamification.dart new file mode 100644 index 0000000..937d7bc --- /dev/null +++ b/lib/models/schema/gamification.dart @@ -0,0 +1,33 @@ +import 'package:hive/hive.dart'; + +part 'gamification.g.dart'; + +@HiveType(typeId: 14) +class Gamification extends HiveObject { + @HiveField(0) + final int ponPoints; + + @HiveField(1) + final String? sakeMbtiType; + + @HiveField(2) + final String? rarityLevel; + + Gamification({ + this.ponPoints = 0, + this.sakeMbtiType, + this.rarityLevel, + }); + + Gamification copyWith({ + int? ponPoints, + String? sakeMbtiType, + String? rarityLevel, + }) { + return Gamification( + ponPoints: ponPoints ?? this.ponPoints, + sakeMbtiType: sakeMbtiType ?? this.sakeMbtiType, + rarityLevel: rarityLevel ?? this.rarityLevel, + ); + } +} diff --git a/lib/models/schema/gamification.g.dart b/lib/models/schema/gamification.g.dart new file mode 100644 index 0000000..eb187e3 --- /dev/null +++ b/lib/models/schema/gamification.g.dart @@ -0,0 +1,47 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'gamification.dart'; + +// ************************************************************************** +// TypeAdapterGenerator +// ************************************************************************** + +class GamificationAdapter extends TypeAdapter { + @override + final int typeId = 14; + + @override + Gamification read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return Gamification( + ponPoints: fields[0] as int, + sakeMbtiType: fields[1] as String?, + rarityLevel: fields[2] as String?, + ); + } + + @override + void write(BinaryWriter writer, Gamification obj) { + writer + ..writeByte(3) + ..writeByte(0) + ..write(obj.ponPoints) + ..writeByte(1) + ..write(obj.sakeMbtiType) + ..writeByte(2) + ..write(obj.rarityLevel); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is GamificationAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} diff --git a/lib/models/schema/hidden_specs.dart b/lib/models/schema/hidden_specs.dart new file mode 100644 index 0000000..e4656c8 --- /dev/null +++ b/lib/models/schema/hidden_specs.dart @@ -0,0 +1,103 @@ +import 'package:hive/hive.dart'; +import 'sake_taste_stats.dart'; + +part 'hidden_specs.g.dart'; + +@HiveType(typeId: 12) +class HiddenSpecs extends HiveObject { + @HiveField(0) + final String? description; + + @HiveField(1) + final Map tasteStats; + + @HiveField(2) + final List flavorTags; + + @HiveField(3) + final double? sweetnessScore; + + @HiveField(4) + final double? bodyScore; + + // Future specs + @HiveField(5) + final String? type; // 特定名称 + + @HiveField(6) + final double? alcoholContent; + + @HiveField(7) + final int? polishingRatio; + + @HiveField(8) + final double? sakeMeterValue; // 日本酒度 + + @HiveField(9) + final String? riceVariety; + + @HiveField(10) + final String? yeast; + + @HiveField(11) + final String? manufacturingYearMonth; + + @HiveField(12) + final String? qrCodeUrl; + + // Helper getter to convert generic Map to typed SakeTasteStats + SakeTasteStats get sakeTasteStats { + if (tasteStats.isEmpty) { + return SakeTasteStats(aroma: 0, richness: 0, sweetness: 0, alcoholFeeling: 0, fruitiness: 0); + } + return SakeTasteStats.fromMap(tasteStats); + } + + HiddenSpecs({ + this.description, + this.tasteStats = const {}, + this.flavorTags = const [], + this.sweetnessScore, + this.bodyScore, + this.type, + this.alcoholContent, + this.polishingRatio, + this.sakeMeterValue, + this.riceVariety, + this.yeast, + this.manufacturingYearMonth, + this.qrCodeUrl, + }); + + HiddenSpecs copyWith({ + String? description, + Map? tasteStats, + List? flavorTags, + double? sweetnessScore, + double? bodyScore, + String? type, + double? alcoholContent, + int? polishingRatio, + double? sakeMeterValue, + String? riceVariety, + String? yeast, + String? manufacturingYearMonth, + String? qrCodeUrl, + }) { + return HiddenSpecs( + description: description ?? this.description, + tasteStats: tasteStats ?? this.tasteStats, + flavorTags: flavorTags ?? this.flavorTags, + sweetnessScore: sweetnessScore ?? this.sweetnessScore, + bodyScore: bodyScore ?? this.bodyScore, + type: type ?? this.type, + alcoholContent: alcoholContent ?? this.alcoholContent, + polishingRatio: polishingRatio ?? this.polishingRatio, + sakeMeterValue: sakeMeterValue ?? this.sakeMeterValue, + riceVariety: riceVariety ?? this.riceVariety, + yeast: yeast ?? this.yeast, + manufacturingYearMonth: manufacturingYearMonth ?? this.manufacturingYearMonth, + qrCodeUrl: qrCodeUrl ?? this.qrCodeUrl, + ); + } +} diff --git a/lib/models/schema/hidden_specs.g.dart b/lib/models/schema/hidden_specs.g.dart new file mode 100644 index 0000000..daf961c --- /dev/null +++ b/lib/models/schema/hidden_specs.g.dart @@ -0,0 +1,77 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'hidden_specs.dart'; + +// ************************************************************************** +// TypeAdapterGenerator +// ************************************************************************** + +class HiddenSpecsAdapter extends TypeAdapter { + @override + final int typeId = 12; + + @override + HiddenSpecs read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return HiddenSpecs( + description: fields[0] as String?, + tasteStats: (fields[1] as Map).cast(), + flavorTags: (fields[2] as List).cast(), + sweetnessScore: fields[3] as double?, + bodyScore: fields[4] as double?, + type: fields[5] as String?, + alcoholContent: fields[6] as double?, + polishingRatio: fields[7] as int?, + sakeMeterValue: fields[8] as double?, + riceVariety: fields[9] as String?, + yeast: fields[10] as String?, + manufacturingYearMonth: fields[11] as String?, + qrCodeUrl: fields[12] as String?, + ); + } + + @override + void write(BinaryWriter writer, HiddenSpecs obj) { + writer + ..writeByte(13) + ..writeByte(0) + ..write(obj.description) + ..writeByte(1) + ..write(obj.tasteStats) + ..writeByte(2) + ..write(obj.flavorTags) + ..writeByte(3) + ..write(obj.sweetnessScore) + ..writeByte(4) + ..write(obj.bodyScore) + ..writeByte(5) + ..write(obj.type) + ..writeByte(6) + ..write(obj.alcoholContent) + ..writeByte(7) + ..write(obj.polishingRatio) + ..writeByte(8) + ..write(obj.sakeMeterValue) + ..writeByte(9) + ..write(obj.riceVariety) + ..writeByte(10) + ..write(obj.yeast) + ..writeByte(11) + ..write(obj.manufacturingYearMonth) + ..writeByte(12) + ..write(obj.qrCodeUrl); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is HiddenSpecsAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} diff --git a/lib/models/schema/item_type.dart b/lib/models/schema/item_type.dart new file mode 100644 index 0000000..f837ade --- /dev/null +++ b/lib/models/schema/item_type.dart @@ -0,0 +1,12 @@ +import 'package:hive/hive.dart'; + +part 'item_type.g.dart'; + +@HiveType(typeId: 16) +enum ItemType { + @HiveField(0) + sake, // 通常銘柄 + + @HiveField(1) + set, // 飲み比べ・セット商品 +} diff --git a/lib/models/schema/item_type.g.dart b/lib/models/schema/item_type.g.dart new file mode 100644 index 0000000..af06d4e --- /dev/null +++ b/lib/models/schema/item_type.g.dart @@ -0,0 +1,46 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'item_type.dart'; + +// ************************************************************************** +// TypeAdapterGenerator +// ************************************************************************** + +class ItemTypeAdapter extends TypeAdapter { + @override + final int typeId = 16; + + @override + ItemType read(BinaryReader reader) { + switch (reader.readByte()) { + case 0: + return ItemType.sake; + case 1: + return ItemType.set; + default: + return ItemType.sake; + } + } + + @override + void write(BinaryWriter writer, ItemType obj) { + switch (obj) { + case ItemType.sake: + writer.writeByte(0); + break; + case ItemType.set: + writer.writeByte(1); + break; + } + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is ItemTypeAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} diff --git a/lib/models/schema/metadata.dart b/lib/models/schema/metadata.dart new file mode 100644 index 0000000..c5db716 --- /dev/null +++ b/lib/models/schema/metadata.dart @@ -0,0 +1,51 @@ +import 'package:hive/hive.dart'; + +part 'metadata.g.dart'; + +@HiveType(typeId: 15) +class Metadata extends HiveObject { + @HiveField(0) + final DateTime createdAt; + + @HiveField(1) + final DateTime? updatedAt; + + @HiveField(2) + final String appType; // "sake", "wine", "beer" + + @HiveField(3) + final String appMode; // "consumer", "business" + + @HiveField(4) + final String version; + + @HiveField(5) + final int? aiConfidence; // Previously confidenceScore + + Metadata({ + required this.createdAt, + this.updatedAt, + this.appType = 'sake', + this.appMode = 'consumer', + this.version = '1.0', + this.aiConfidence, + }); + + Metadata copyWith({ + DateTime? createdAt, + DateTime? updatedAt, + String? appType, + String? appMode, + String? version, + int? aiConfidence, + }) { + return Metadata( + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + appType: appType ?? this.appType, + appMode: appMode ?? this.appMode, + version: version ?? this.version, + aiConfidence: aiConfidence ?? this.aiConfidence, + ); + } +} diff --git a/lib/models/schema/metadata.g.dart b/lib/models/schema/metadata.g.dart new file mode 100644 index 0000000..61fea9e --- /dev/null +++ b/lib/models/schema/metadata.g.dart @@ -0,0 +1,56 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'metadata.dart'; + +// ************************************************************************** +// TypeAdapterGenerator +// ************************************************************************** + +class MetadataAdapter extends TypeAdapter { + @override + final int typeId = 15; + + @override + Metadata read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return Metadata( + createdAt: fields[0] as DateTime, + updatedAt: fields[1] as DateTime?, + appType: fields[2] as String, + appMode: fields[3] as String, + version: fields[4] as String, + aiConfidence: fields[5] as int?, + ); + } + + @override + void write(BinaryWriter writer, Metadata obj) { + writer + ..writeByte(6) + ..writeByte(0) + ..write(obj.createdAt) + ..writeByte(1) + ..write(obj.updatedAt) + ..writeByte(2) + ..write(obj.appType) + ..writeByte(3) + ..write(obj.appMode) + ..writeByte(4) + ..write(obj.version) + ..writeByte(5) + ..write(obj.aiConfidence); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is MetadataAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} diff --git a/lib/models/schema/sake_taste_stats.dart b/lib/models/schema/sake_taste_stats.dart new file mode 100644 index 0000000..60cb2e0 --- /dev/null +++ b/lib/models/schema/sake_taste_stats.dart @@ -0,0 +1,40 @@ +import 'package:hive/hive.dart'; + +part 'sake_taste_stats.g.dart'; + +@HiveType(typeId: 21) // Ensure ID 21 is free or managed +class SakeTasteStats extends HiveObject { + @HiveField(0) + final double aroma; // 香り + + @HiveField(1) + final double richness; // コク + + @HiveField(2) + final double sweetness; // 甘み + + @HiveField(3) + final double alcoholFeeling; // アルコール感 (キレ) + + @HiveField(4) + final double fruitiness; // フルーティさ + + SakeTasteStats({ + required this.aroma, + required this.richness, + required this.sweetness, + required this.alcoholFeeling, + required this.fruitiness, + }); + + // Factory to convert from Map (legacy/current HiddenSpecs format) + factory SakeTasteStats.fromMap(Map map) { + return SakeTasteStats( + aroma: (map['aroma'] ?? 0).toDouble(), + richness: (map['richness'] ?? 0).toDouble(), + sweetness: (map['sweetness'] ?? 0).toDouble(), + alcoholFeeling: (map['alcohol'] ?? 0).toDouble(), + fruitiness: (map['fruitiness'] ?? 0).toDouble(), + ); + } +} diff --git a/lib/models/schema/sake_taste_stats.g.dart b/lib/models/schema/sake_taste_stats.g.dart new file mode 100644 index 0000000..b529567 --- /dev/null +++ b/lib/models/schema/sake_taste_stats.g.dart @@ -0,0 +1,53 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'sake_taste_stats.dart'; + +// ************************************************************************** +// TypeAdapterGenerator +// ************************************************************************** + +class SakeTasteStatsAdapter extends TypeAdapter { + @override + final int typeId = 21; + + @override + SakeTasteStats read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return SakeTasteStats( + aroma: fields[0] as double, + richness: fields[1] as double, + sweetness: fields[2] as double, + alcoholFeeling: fields[3] as double, + fruitiness: fields[4] as double, + ); + } + + @override + void write(BinaryWriter writer, SakeTasteStats obj) { + writer + ..writeByte(5) + ..writeByte(0) + ..write(obj.aroma) + ..writeByte(1) + ..write(obj.richness) + ..writeByte(2) + ..write(obj.sweetness) + ..writeByte(3) + ..write(obj.alcoholFeeling) + ..writeByte(4) + ..write(obj.fruitiness); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is SakeTasteStatsAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} diff --git a/lib/models/schema/user_data.dart b/lib/models/schema/user_data.dart new file mode 100644 index 0000000..dd28be2 --- /dev/null +++ b/lib/models/schema/user_data.dart @@ -0,0 +1,76 @@ +import 'package:hive/hive.dart'; + +part 'user_data.g.dart'; + +@HiveType(typeId: 13) +class UserData extends HiveObject { + @HiveField(0) + final bool isFavorite; + + @HiveField(1) + final bool isUserEdited; + + @HiveField(2) + final String? memo; + + // Pricing (Business Mode) + @HiveField(3) + final int? price; // Selling Price (User defined) + + @HiveField(4) + final int? costPrice; + + @HiveField(5) + final double markup; + + @HiveField(6) + final Map? priceVariants; + + @HiveField(7) + final String? purchaseLocation; + + @HiveField(8) + final String? drinkLocation; // For Consumer Mode + + @HiveField(9) + final String? companion; // For Consumer Mode + + UserData({ + this.isFavorite = false, + this.isUserEdited = false, + this.memo, + this.price, + this.costPrice, + this.markup = 3.0, + this.priceVariants, + this.purchaseLocation, + this.drinkLocation, + this.companion, + }); + + UserData copyWith({ + bool? isFavorite, + bool? isUserEdited, + String? memo, + int? price, + int? costPrice, + double? markup, + Map? priceVariants, + String? purchaseLocation, + String? drinkLocation, + String? companion, + }) { + return UserData( + isFavorite: isFavorite ?? this.isFavorite, + isUserEdited: isUserEdited ?? this.isUserEdited, + memo: memo ?? this.memo, + price: price ?? this.price, + costPrice: costPrice ?? this.costPrice, + markup: markup ?? this.markup, + priceVariants: priceVariants ?? this.priceVariants, + purchaseLocation: purchaseLocation ?? this.purchaseLocation, + drinkLocation: drinkLocation ?? this.drinkLocation, + companion: companion ?? this.companion, + ); + } +} diff --git a/lib/models/schema/user_data.g.dart b/lib/models/schema/user_data.g.dart new file mode 100644 index 0000000..2afe0ab --- /dev/null +++ b/lib/models/schema/user_data.g.dart @@ -0,0 +1,68 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'user_data.dart'; + +// ************************************************************************** +// TypeAdapterGenerator +// ************************************************************************** + +class UserDataAdapter extends TypeAdapter { + @override + final int typeId = 13; + + @override + UserData read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return UserData( + isFavorite: fields[0] as bool, + isUserEdited: fields[1] as bool, + memo: fields[2] as String?, + price: fields[3] as int?, + costPrice: fields[4] as int?, + markup: fields[5] as double, + priceVariants: (fields[6] as Map?)?.cast(), + purchaseLocation: fields[7] as String?, + drinkLocation: fields[8] as String?, + companion: fields[9] as String?, + ); + } + + @override + void write(BinaryWriter writer, UserData obj) { + writer + ..writeByte(10) + ..writeByte(0) + ..write(obj.isFavorite) + ..writeByte(1) + ..write(obj.isUserEdited) + ..writeByte(2) + ..write(obj.memo) + ..writeByte(3) + ..write(obj.price) + ..writeByte(4) + ..write(obj.costPrice) + ..writeByte(5) + ..write(obj.markup) + ..writeByte(6) + ..write(obj.priceVariants) + ..writeByte(7) + ..write(obj.purchaseLocation) + ..writeByte(8) + ..write(obj.drinkLocation) + ..writeByte(9) + ..write(obj.companion); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is UserDataAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} diff --git a/lib/models/user_profile.dart b/lib/models/user_profile.dart new file mode 100644 index 0000000..3edec73 --- /dev/null +++ b/lib/models/user_profile.dart @@ -0,0 +1,87 @@ +import 'package:hive/hive.dart'; + +part 'user_profile.g.dart'; + +@HiveType(typeId: 1) +class UserProfile extends HiveObject { + @HiveField(0) + // DEPRECATED: String? mbtiType; + // Hive indices must not be reused or reordered carelessly. + // Ideally we keep the index but ignore it, or just use the old index for new logic. + // The user review suggested: "Remove old fields... or unify". + // Since we already used new indices (6, 7) in the previous step and ran build_runner, + // keeping 6 and 7 is safer if we want to avoid migration issues with recently created data. + // But since this is dev, clean up is better. + // However, I will follow the user's advice to remove the old ones. + // I will comment them out to reserve the index if needed, or just remove. + // Let's remove them and their constructor args. + + @HiveField(2) + String fontPreference; // "serif" or "sans" + + @HiveField(3) + DateTime createdAt; + + @HiveField(4) + DateTime? updatedAt; + + @HiveField(5) + String displayMode; // "list" or "grid" + + @HiveField(6) + String? mbti; + + @HiveField(7) + DateTime? birthdate; + + @HiveField(8) + String themeMode; // 'system', 'light', 'dark' + + @HiveField(9, defaultValue: false) + bool isBusinessMode; + + @HiveField(10, defaultValue: 3.0) + double defaultMarkup; + + @HiveField(11, defaultValue: false) + bool hasCompletedOnboarding; + + UserProfile({ + this.fontPreference = 'sans', + this.displayMode = 'list', + required this.createdAt, + this.updatedAt, + this.mbti, + this.birthdate, + this.themeMode = 'system', + this.isBusinessMode = false, + this.defaultMarkup = 3.0, + this.hasCompletedOnboarding = false, + }); + + UserProfile copyWith({ + String? fontPreference, + String? displayMode, + DateTime? createdAt, + DateTime? updatedAt, + String? mbti, + DateTime? birthdate, + String? themeMode, + bool? isBusinessMode, + double? defaultMarkup, + bool? hasCompletedOnboarding, + }) { + return UserProfile( + fontPreference: fontPreference ?? this.fontPreference, + displayMode: displayMode ?? this.displayMode, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + mbti: mbti ?? this.mbti, + birthdate: birthdate ?? this.birthdate, + themeMode: themeMode ?? this.themeMode, + isBusinessMode: isBusinessMode ?? this.isBusinessMode, + defaultMarkup: defaultMarkup ?? this.defaultMarkup, + hasCompletedOnboarding: hasCompletedOnboarding ?? this.hasCompletedOnboarding, + ); + } +} diff --git a/lib/models/user_profile.g.dart b/lib/models/user_profile.g.dart new file mode 100644 index 0000000..e467e21 --- /dev/null +++ b/lib/models/user_profile.g.dart @@ -0,0 +1,68 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'user_profile.dart'; + +// ************************************************************************** +// TypeAdapterGenerator +// ************************************************************************** + +class UserProfileAdapter extends TypeAdapter { + @override + final int typeId = 1; + + @override + UserProfile read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return UserProfile( + fontPreference: fields[0] as String, + displayMode: fields[5] as String, + createdAt: fields[3] as DateTime, + updatedAt: fields[4] as DateTime?, + mbti: fields[6] as String?, + birthdate: fields[7] as DateTime?, + themeMode: fields[8] as String, + isBusinessMode: fields[9] == null ? false : fields[9] as bool, + defaultMarkup: fields[10] == null ? 3.0 : fields[10] as double, + hasCompletedOnboarding: fields[11] == null ? false : fields[11] as bool, + ); + } + + @override + void write(BinaryWriter writer, UserProfile obj) { + writer + ..writeByte(10) + ..writeByte(0) + ..write(obj.fontPreference) + ..writeByte(3) + ..write(obj.createdAt) + ..writeByte(4) + ..write(obj.updatedAt) + ..writeByte(5) + ..write(obj.displayMode) + ..writeByte(6) + ..write(obj.mbti) + ..writeByte(7) + ..write(obj.birthdate) + ..writeByte(8) + ..write(obj.themeMode) + ..writeByte(9) + ..write(obj.isBusinessMode) + ..writeByte(10) + ..write(obj.defaultMarkup) + ..writeByte(11) + ..write(obj.hasCompletedOnboarding); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is UserProfileAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} diff --git a/lib/providers/display_mode_provider.dart b/lib/providers/display_mode_provider.dart new file mode 100644 index 0000000..81ffa30 --- /dev/null +++ b/lib/providers/display_mode_provider.dart @@ -0,0 +1,36 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:hive_flutter/hive_flutter.dart'; +import '../models/user_profile.dart'; +import 'theme_provider.dart'; + +// Provider for the Display Mode +final displayModeProvider = NotifierProvider(DisplayModeNotifier.new); + +class DisplayModeNotifier extends Notifier { + late UserProfile _profile; + late Box _box; + + @override + String build() { + _box = ref.watch(userProfileBoxProvider); + _profile = _box.get('current_user') ?? UserProfile(createdAt: DateTime.now()); + return _profile.displayMode; + } + + Future setDisplayMode(String mode) async { + state = mode; + _profile.displayMode = mode; + _profile.updatedAt = DateTime.now(); + + // Save to box (use put if not yet in box, otherwise save) + if (_profile.isInBox) { + await _profile.save(); + } else { + await _box.put('current_user', _profile); + } + } + + void toggle() { + setDisplayMode(state == 'list' ? 'grid' : 'list'); + } +} diff --git a/lib/providers/filter_providers.dart b/lib/providers/filter_providers.dart new file mode 100644 index 0000000..40ce3d3 --- /dev/null +++ b/lib/providers/filter_providers.dart @@ -0,0 +1,34 @@ + +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +// Search Query Notifier +class SearchQueryNotifier extends Notifier { + @override + String build() => ''; + void set(String value) => state = value; +} +final sakeSearchQueryProvider = NotifierProvider(SearchQueryNotifier.new); + +// Favorite Filter Notifier +class FavoriteFilterNotifier extends Notifier { + @override + bool build() => false; + void toggle() => state = !state; + void set(bool value) => state = value; +} +final sakeFilterFavoriteProvider = NotifierProvider(FavoriteFilterNotifier.new); + +// Filter Tag Notifier +class FilterTagNotifier extends Notifier { + @override + String? build() => null; + void set(String? value) => state = value; +} +final sakeFilterTagProvider = NotifierProvider(FilterTagNotifier.new); +// Filter Prefecture Notifier +class FilterPrefectureNotifier extends Notifier { + @override + String? build() => null; + void set(String? value) => state = value; +} +final sakeFilterPrefectureProvider = NotifierProvider(FilterPrefectureNotifier.new); diff --git a/lib/providers/menu_providers.dart b/lib/providers/menu_providers.dart new file mode 100644 index 0000000..3d7c20b --- /dev/null +++ b/lib/providers/menu_providers.dart @@ -0,0 +1,121 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:hive_flutter/hive_flutter.dart'; +import 'package:pdf/pdf.dart'; // PdfPageFormat +import '../models/sake_item.dart'; + +// 1. Menu Mode Toggle +final menuModeProvider = NotifierProvider(MenuModeNotifier.new); + +class MenuModeNotifier extends Notifier { + @override + bool build() => false; + + void set(bool value) => state = value; + void toggle() => state = !state; +} + +// 2. Selected Sake IDs +class SelectedMenuSakeNotifier extends Notifier> { + @override + Set build() => {}; + + void toggle(String id) { + if (state.contains(id)) { + state = {...state}..remove(id); + } else { + state = {...state, id}; + } + } + + void clear() => state = {}; + + bool isSelected(String id) => state.contains(id); +} + +final selectedMenuSakeIdsProvider = NotifierProvider>(SelectedMenuSakeNotifier.new); + + +// 3. Menu Order +final menuOrderedIdsProvider = NotifierProvider>(MenuOrderedIdsNotifier.new); + +class MenuOrderedIdsNotifier extends Notifier> { + @override + List build() => []; + + void initialize(List currentSelection) { + state = List.from(currentSelection); + } + + void reorder(int oldIndex, int newIndex) { + if (oldIndex < newIndex) { + newIndex -= 1; + } + final item = state.removeAt(oldIndex); + state.insert(newIndex, item); + state = [...state]; // Trigger notify + } +} + +// 4. PDF Settings Providers (Phase 4) +// Note: We use Notifiers for complex logic, but simple StateProviders (Riverpod 2.0 style) are fine here. +// Actually, Riverpod recommended is Notifier, but StateProvider is still available. +// Let's use simple class-based Notifiers for 2.0 strictness if needed, or simple State for brevity. +// We will use StateProvider for now as they are declared in the previous snippets as "Notifiers" but let's stick to simple Notifiers or StateProvider if imported. +// In the viewed file, I only see NotifierProvider. Let's add simple Notifiers. + +final pdfPageFormatProvider = NotifierProvider(PdfPageFormatNotifier.new); +class PdfPageFormatNotifier extends Notifier { + @override + PdfPageFormat build() => PdfPageFormat.a4; // Default Portrait A4. + // Wait, A4 is actually "Portrait" by default in pdf package? Yes. + void set(PdfPageFormat format) => state = format; +} + +final pdfIsPortraitProvider = NotifierProvider(PdfIsPortraitNotifier.new); +class PdfIsPortraitNotifier extends Notifier { + @override + bool build() => true; // Default Portrait + void set(bool value) => state = value; +} + +final pdfIsMonochromeProvider = NotifierProvider(PdfIsMonochromeNotifier.new); +class PdfIsMonochromeNotifier extends Notifier { + @override + bool build() => false; + void set(bool value) => state = value; +} + +final pdfIncludePriceProvider = NotifierProvider(PdfIncludePriceNotifier.new); +class PdfIncludePriceNotifier extends Notifier { + @override + bool build() => true; + void set(bool value) => state = value; +} + +final pdfDensityProvider = NotifierProvider(PdfDensityNotifier.new); +class PdfDensityNotifier extends Notifier { + @override + double build() => 1.2; // Default 1.2 "High Density" + void set(double value) => state = value; +} + +// 4. Show Selected Only Toggle +final menuShowSelectedOnlyProvider = NotifierProvider(MenuShowSelectedNotifier.new); + +class MenuShowSelectedNotifier extends Notifier { + @override + bool build() => false; + void toggle() => state = !state; + void set(bool value) => state = value; +} + + +// Logic to initialize menu order when entering mode or selecting items? +// Maybe easier: +// HomeScreen filters `rawList` by `selectedIds`. +// Result is a list. +// If the user reorders this list, we need to store the new order. +// Let's use `menuOrderedIdsProvider` to store the authoritative order of the MENU. + +// When generating PDF, we read `menuOrderedIdsProvider` (or the current list in UI). + diff --git a/lib/providers/sake_list_provider.dart b/lib/providers/sake_list_provider.dart new file mode 100644 index 0000000..f695786 --- /dev/null +++ b/lib/providers/sake_list_provider.dart @@ -0,0 +1,163 @@ + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:hive_flutter/hive_flutter.dart'; +import '../models/sake_item.dart'; +import 'filter_providers.dart'; + +// 1. Raw List Stream +final rawSakeListItemsProvider = StreamProvider>((ref) { + final box = Hive.box('sake_items'); + // Use startWith to emit current value immediately + return box.watch().map((event) => box.values.toList()).startWith(box.values.toList()); +}); + +// 2. Sort Order Stream +final sakeSortOrderProvider = StreamProvider>((ref) { + final box = Hive.box('settings'); + return box.watch(key: 'sake_sort_order').map((event) { + final List? stored = box.get('sake_sort_order'); + return stored?.cast() ?? []; + }).startWith(box.get('sake_sort_order')?.cast() ?? []); +}); + +// Sort Mode Enum +enum SortMode { newest, oldest, name, custom } + +class SakeSortModeNotifier extends Notifier { + @override + SortMode build() => SortMode.newest; + + void set(SortMode mode) => state = mode; +} + +final sakeSortModeProvider = NotifierProvider(SakeSortModeNotifier.new); + +// 3. Combined Sorted List +final sakeListProvider = Provider>>((ref) { + final rawListAsync = ref.watch(rawSakeListItemsProvider); + final sortOrderAsync = ref.watch(sakeSortOrderProvider); + final sortMode = ref.watch(sakeSortModeProvider); + + // Watch Filters + final searchQuery = ref.watch(sakeSearchQueryProvider).toLowerCase(); + final showFavoritesOnly = ref.watch(sakeFilterFavoriteProvider); + final filterTag = ref.watch(sakeFilterTagProvider); + final filterPrefecture = ref.watch(sakeFilterPrefectureProvider); + + return rawListAsync.when( + data: (rawList) { + if (rawList.isEmpty) return const AsyncValue.data([]); + + // 1. First, apply filters to raw list + var filtered = rawList.where((item) { + // Search Filter + if (searchQuery.isNotEmpty) { + final matches = item.displayData.name.toLowerCase().contains(searchQuery) || + item.displayData.brewery.toLowerCase().contains(searchQuery) || + item.displayData.prefecture.toLowerCase().contains(searchQuery); + if (!matches) return false; + } + + // Favorite Filter + if (showFavoritesOnly) { + if (!item.userData.isFavorite) return false; + } + + // Prefecture Filter + if (filterPrefecture != null) { + if (item.displayData.prefecture != filterPrefecture) return false; + } + + // Tag Filter + if (filterTag != null && filterTag != 'All') { + // Special case for 'Set' which effectively filters by itemType if we had it, or implicit tag + if (filterTag == 'Set') { + // Assuming 'Set' tag is manually added or we check itemType if implemented + // For now, check flavorTags for 'Set' or 'セット' + final isSet = item.hiddenSpecs.flavorTags.contains('セット') || + item.hiddenSpecs.flavorTags.contains('Set') || + item.displayData.name.contains('セット'); + if (!isSet) return false; + } else { + final matchesTag = item.hiddenSpecs.flavorTags.contains(filterTag); + if (!matchesTag) return false; + } + } + + return true; + }).toList(); + + // 2. Then apply sort based on SortMode + switch (sortMode) { + case SortMode.newest: + filtered.sort((a, b) => b.metadata.createdAt.compareTo(a.metadata.createdAt)); + break; + case SortMode.oldest: + filtered.sort((a, b) => a.metadata.createdAt.compareTo(b.metadata.createdAt)); + break; + case SortMode.name: + filtered.sort((a, b) => a.displayData.name.compareTo(b.displayData.name)); + break; + case SortMode.custom: + default: + // Use Manual Sort Order + return sortOrderAsync.when( + data: (sortOrder) { + if (sortOrder.isEmpty) { + // Default fallback if no custom order + filtered.sort((a, b) => b.metadata.createdAt.compareTo(a.metadata.createdAt)); + return AsyncValue.data(filtered); + } + + final orderMap = {for (var i = 0; i < sortOrder.length; i++) sortOrder[i]: i}; + + filtered.sort((a, b) { + final idxA = orderMap[a.id]; + final idxB = orderMap[b.id]; + + if (idxA != null && idxB != null) { + return idxA.compareTo(idxB); + } else if (idxA != null) { + return -1; + } else if (idxB != null) { + return 1; + } else { + return b.metadata.createdAt.compareTo(a.metadata.createdAt); + } + }); + + return AsyncValue.data(filtered); + }, + loading: () => AsyncValue.data(filtered), + error: (e, s) => AsyncValue.error(e, s), + ); + } + + return AsyncValue.data(filtered); + }, + loading: () => const AsyncValue.loading(), + error: (e, s) => AsyncValue.error(e, s), + ); +}); + + +// 4. Controller for Updating Order +class SakeOrderController extends Notifier { + @override + void build() {} + + void updateOrder(List newOrder) { + final box = Hive.box('settings'); + final ids = newOrder.map((e) => e.id).toList(); + box.put('sake_sort_order', ids); + } +} + +final sakeOrderControllerProvider = NotifierProvider(SakeOrderController.new); + +extension StreamStartWith on Stream { + Stream startWith(T initial) async* { + yield initial; + yield* this; + } +} diff --git a/lib/providers/theme_provider.dart b/lib/providers/theme_provider.dart new file mode 100644 index 0000000..2e2636c --- /dev/null +++ b/lib/providers/theme_provider.dart @@ -0,0 +1,104 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter/material.dart'; +import 'package:hive_flutter/hive_flutter.dart'; +import '../models/user_profile.dart'; +import '../theme/app_theme.dart'; + +// Provider for the UserProfile box +final userProfileBoxProvider = Provider>((ref) { + throw UnimplementedError('Provider was not initialized'); +}); + +// Central Notifier for User Profile (Font, Theme, Identity) +final userProfileProvider = NotifierProvider(UserProfileNotifier.new); + +class UserProfileNotifier extends Notifier { + late Box _box; + + @override + UserProfile build() { + _box = ref.watch(userProfileBoxProvider); + // Return existing profile or create default + return _box.get('current_user') ?? + UserProfile(createdAt: DateTime.now()); + } + + Future _save(UserProfile profile) async { + state = profile; // Update UI immediately + await _box.put('current_user', profile); + } + + Future setFontPreference(String preference) async { + final newState = state.copyWith( + fontPreference: preference, + updatedAt: DateTime.now(), + ); + await _save(newState); + } + + Future setThemeMode(String mode) async { + final newState = state.copyWith( + themeMode: mode, + updatedAt: DateTime.now(), + ); + await _save(newState); + } + + Future setIdentity({String? mbti, DateTime? birthdate}) async { + final newState = state.copyWith( + mbti: mbti ?? state.mbti, + birthdate: birthdate ?? state.birthdate, + updatedAt: DateTime.now(), + ); + await _save(newState); + } + + Future toggleBusinessMode() async { + final newState = state.copyWith( + isBusinessMode: !state.isBusinessMode, + updatedAt: DateTime.now(), + ); + await _save(newState); + } + + Future setDefaultMarkup(double value) async { + final newState = state.copyWith( + defaultMarkup: value, + updatedAt: DateTime.now(), + ); + await _save(newState); + } + + Future completeOnboarding() async { + final newState = state.copyWith( + hasCompletedOnboarding: true, + updatedAt: DateTime.now(), + ); + await _save(newState); + } +} + +// Helper Providers for easy access +final fontPreferenceProvider = Provider((ref) { + return ref.watch(userProfileProvider).fontPreference; +}); + +final themeModeProvider = Provider((ref) { + final mode = ref.watch(userProfileProvider).themeMode; + switch (mode) { + case 'light': return ThemeMode.light; + case 'dark': return ThemeMode.dark; + default: return ThemeMode.system; + } +}); + +// Theme Data Providers +final lightThemeProvider = Provider((ref) { + final font = ref.watch(fontPreferenceProvider); + return AppTheme.createTheme(font, Brightness.light); +}); + +final darkThemeProvider = Provider((ref) { + final font = ref.watch(fontPreferenceProvider); + return AppTheme.createTheme(font, Brightness.dark); +}); diff --git a/lib/screens/camera_screen.dart b/lib/screens/camera_screen.dart new file mode 100644 index 0000000..17c73e8 --- /dev/null +++ b/lib/screens/camera_screen.dart @@ -0,0 +1,652 @@ +import 'dart:async'; // Timer +import 'dart:io'; +import 'package:camera/camera.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:hive_flutter/hive_flutter.dart'; +import 'package:path/path.dart' show join; +import 'package:path_provider/path_provider.dart'; +import 'package:uuid/uuid.dart'; +import 'package:gal/gal.dart'; + +import '../services/gemini_service.dart'; +import '../services/ocr_service.dart'; +import '../widgets/analyzing_dialog.dart'; +import '../models/sake_item.dart'; +import '../providers/sake_list_provider.dart'; +import '../theme/app_theme.dart'; +import 'package:lucide_icons/lucide_icons.dart'; + +enum CameraMode { + createItem, + returnPath, +} + +class CameraScreen extends ConsumerStatefulWidget { + final CameraMode mode; + const CameraScreen({super.key, this.mode = CameraMode.createItem}); + + @override + ConsumerState createState() => _CameraScreenState(); +} + +class _CameraScreenState extends ConsumerState with SingleTickerProviderStateMixin { + CameraController? _controller; + Future? _initializeControllerFuture; + bool _isTakingPicture = false; + DateTime? _quotaLockoutTime; + + // Phase 3-B: Focus & Zoom + double _minZoom = 1.0; + double _maxZoom = 1.0; + double _currentZoom = 1.0; + double _baseScale = 1.0; // For pinch reference + + // Phase 3-B2: Exposure + double _minExposure = 0.0; + double _maxExposure = 0.0; + double _currentExposureOffset = 0.0; + + Offset? _focusPoint; + bool _showFocusRing = false; + Timer? _focusRingTimer; + DateTime? _lastExposureUpdate; // Throttling state + + @override + void initState() { + super.initState(); + _initializeCamera(); + } + + Future _initializeCamera() async { + final cameras = await availableCameras(); + final firstCamera = cameras.first; + + _controller = CameraController( + firstCamera, + ResolutionPreset.high, + enableAudio: false, + imageFormatGroup: ImageFormatGroup.jpeg, + ); + + _initializeControllerFuture = _controller!.initialize().then((_) async { + if (!mounted) return; + // [Phase 3-B] Get Zoom Range + _minZoom = await _controller!.getMinZoomLevel(); + _maxZoom = await _controller!.getMaxZoomLevel(); + // [Phase 3-B2] Get Exposure Range + _minExposure = await _controller!.getMinExposureOffset(); + _maxExposure = await _controller!.getMaxExposureOffset(); + + // [Phase 3-B] Set Auto Focus Mode + await _controller!.setFocusMode(FocusMode.auto); + setState(() {}); + }); + } + + @override + void dispose() { + _controller?.dispose(); + _focusRingTimer?.cancel(); + super.dispose(); + } + + // ... (Keep existing methods: _capturedImages, _takePicture, _analyzeImages) + + double? _pendingExposureValue; + bool _isUpdatingExposure = false; + + Future _setExposureSafe(double value) async { + _pendingExposureValue = value; + if (_isUpdatingExposure) return; + + _isUpdatingExposure = true; + while (_pendingExposureValue != null) { + final valueToSet = _pendingExposureValue!; + _pendingExposureValue = null; + try { + if (_controller != null && _controller!.value.isInitialized) { + await _controller!.setExposureOffset(valueToSet); + } + } catch (e) { + debugPrint('Exposure update error: $e'); + } + } + _isUpdatingExposure = false; + } + + void _onTapFocus(TapUpDetails details, BoxConstraints constraints) async { + if (_controller == null || !_controller!.value.isInitialized) return; + + // Normalizing coordinates (0.0 - 1.0) + final offset = Offset( + details.localPosition.dx / constraints.maxWidth, + details.localPosition.dy / constraints.maxHeight, + ); + + try { + await _controller!.setFocusPoint(offset); + await _controller!.setFocusMode(FocusMode.auto); + } catch (e) { + // Some devices might not support focus point + debugPrint('Focus failed: $e'); + } + + if (!mounted) return; + + setState(() { + _focusPoint = details.localPosition; + _showFocusRing = true; + }); + + _focusRingTimer?.cancel(); + _focusRingTimer = Timer(const Duration(milliseconds: 1000), () { + if (mounted) setState(() => _showFocusRing = false); + }); + } + + void _onScaleStart(ScaleStartDetails details) { + _baseScale = _currentZoom; + } + + Future _onScaleUpdate(ScaleUpdateDetails details) async { + if (_controller == null || !_controller!.value.isInitialized) return; + + // Calculate new zoom level + final newZoom = (_baseScale * details.scale).clamp(_minZoom, _maxZoom); + + // Optimize: 0.1 increments (Prevent jitter) + if ((newZoom - _currentZoom).abs() < 0.1) return; + + try { + await _controller!.setZoomLevel(newZoom); + setState(() => _currentZoom = newZoom); + } catch (e) { + debugPrint('Zoom failed: $e'); + } + } + + List _capturedImages = []; + + Future _takePicture() async { + // Check Quota Lockout + if (_quotaLockoutTime != null) { + final remaining = _quotaLockoutTime!.difference(DateTime.now()); + if (remaining.isNegative) { + setState(() => _quotaLockoutTime = null); // Reset + } else { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('AI利用制限中です。あと${remaining.inSeconds}秒お待ちください。')), + ); + return; + } + } + + if (_isTakingPicture || _controller == null || !_controller!.value.isInitialized) { + return; + } + + setState(() { + _isTakingPicture = true; + }); + + try { + await _initializeControllerFuture; + final image = await _controller!.takePicture(); + + // Save image locally (App Sandbox) + final directory = await getApplicationDocumentsDirectory(); + final String imagePath = join(directory.path, '${const Uuid().v4()}.jpg'); + await image.saveTo(imagePath); + + // Save to Gallery (Public) - Phase 4: Data Safety + try { + await Gal.putImage(imagePath); + debugPrint('Saved to Gallery: $imagePath'); + } catch (e) { + debugPrint('Gallery Save Error: $e'); + // Don't block flow, but maybe notify? + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('ギャラリー保存に失敗しました: $e'), duration: const Duration(seconds: 1)), + ); + } + } + + if (!mounted) return; + + // IF RETURN PATH Mode + if (widget.mode == CameraMode.returnPath) { + Navigator.of(context).pop(imagePath); + return; + } + + _capturedImages.add(imagePath); + + // Show Confirmation Dialog + await showDialog( + context: context, + barrierDismissible: false, + builder: (ctx) => AlertDialog( + title: const Text('写真を保存しました'), + content: const Text('さらに別の面も撮影すると、\nAI解析の精度が大幅にアップします!'), + actions: [ + OutlinedButton( + onPressed: () { + // Start Analysis + Navigator.of(context).pop(); + _analyzeImages(); + }, + child: const Text('解析開始'), + ), + FilledButton( + onPressed: () { + // Take another photo (Dismiss dialog) + Navigator.of(context).pop(); + }, + style: FilledButton.styleFrom( + backgroundColor: AppTheme.posimaiBlue, + foregroundColor: Colors.white, + ), + child: const Text('さらに撮影'), + ), + ], + ), + ); + + } catch (e) { + debugPrint('Capture Error: $e'); + } finally { + if (mounted) { + setState(() { + _isTakingPicture = false; + }); + } + } + } + + Future _analyzeImages() async { + if (_capturedImages.isEmpty) return; + + // Show AnalyzingDialog + showDialog( + context: context, + barrierDismissible: false, + builder: (context) => const AnalyzingDialog(), + ); + + try { + // [Phase 3-C Revised] Hybrid Analysis Implementation + final ocrService = OcrService(); + final StringBuffer extractedBuffer = StringBuffer(); + + try { + for (final path in _capturedImages) { + final text = await ocrService.extractText(path); + if (text.isNotEmpty) { + extractedBuffer.writeln(text); + } + } + } finally { + ocrService.dispose(); // Ensure resources are released + } + + final extractedText = extractedBuffer.toString().trim(); + debugPrint('OCR Extracted Text (${extractedText.length} chars):'); + if (extractedText.isNotEmpty) { + debugPrint(extractedText.substring(0, extractedText.length > 100 ? 100 : extractedText.length) + '...'); + } + + // Hybrid Decision Logic (Threshold: 30 chars) + SakeAnalysisResult result; + final geminiService = GeminiService(); + + if (extractedText.length > 30) { + debugPrint('✅ OCR SUCCESS: Using Hybrid Analysis (Text + Images)'); + // Send both text and images (images allow AI to correct OCR errors) + result = await geminiService.analyzeSakeHybrid(extractedText, _capturedImages); + } else { + debugPrint('⚠️ OCR INSUFFICIENT (${extractedText.length} chars): Fallback to Image Analysis'); + result = await geminiService.analyzeSakeLabel(_capturedImages); + } + + // Create SakeItem + // Create SakeItem (Schema v2.0) + final sakeItem = SakeItem( + id: const Uuid().v4(), + displayData: DisplayData( + name: result.name ?? '不明な日本酒', + brewery: result.brand ?? '不明', + prefecture: result.prefecture ?? '不明', + catchCopy: result.catchCopy, + imagePaths: List.from(_capturedImages), + rating: null, + ), + hiddenSpecs: HiddenSpecs( + description: result.description, + tasteStats: result.tasteStats, + flavorTags: result.flavorTags, + ), + metadata: Metadata( + createdAt: DateTime.now(), + aiConfidence: result.confidenceScore, + ), + ); + + // Save to Hive + final box = Hive.box('sake_items'); + await box.add(sakeItem); + + // Prepend new item to sort order so it appears at the top + final settingsBox = Hive.box('settings'); + final List currentOrder = (settingsBox.get('sake_sort_order') as List?) + ?.cast() ?? []; + currentOrder.insert(0, sakeItem.id); // Insert at beginning + await settingsBox.put('sake_sort_order', currentOrder); + + // Debug: Verify save + debugPrint('✅ Saved to Hive: ${sakeItem.displayData.name} (ID: ${sakeItem.id})'); + debugPrint('📦 Total items in box: ${box.length}'); + debugPrint('📦 Prepended to sort order (now ${currentOrder.length} items)'); + + if (!mounted) return; + + // Close Dialog + Navigator.of(context).pop(); + + // Close Camera Screen (Return to Home) + Navigator.of(context).pop(); + + // Success Message + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('${sakeItem.displayData.name} を登録しました!'), + duration: const Duration(seconds: 2), + ), + ); + + } catch (e) { + if (mounted) { + Navigator.of(context).pop(); // Close AnalyzingDialog + + // Check for Quota Error to set Lockout + if (e.toString().contains('Quota') || e.toString().contains('429')) { + setState(() { + _quotaLockoutTime = DateTime.now().add(const Duration(minutes: 1)); + }); + } + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('解析エラー: $e')), + ); + } + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.black, + body: FutureBuilder( + future: _initializeControllerFuture, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.done) { + // Camera Preview with Focus/Zoom + return Stack( + fit: StackFit.expand, + children: [ + LayoutBuilder( + builder: (context, constraints) { + return GestureDetector( + onScaleStart: _onScaleStart, + onScaleUpdate: _onScaleUpdate, + onTapUp: (details) => _onTapFocus(details, constraints), + child: CameraPreview(_controller!), + ); + } + ), + + // Focus Ring Overlay + if (_showFocusRing && _focusPoint != null) + Positioned( + left: _focusPoint!.dx - 40, + top: _focusPoint!.dy - 40, + child: Container( + width: 80, + height: 80, + decoration: BoxDecoration( + border: Border.all(color: Colors.yellow, width: 2), + borderRadius: BorderRadius.circular(40), + ), + ), + ), + + // Instagram-style Exposure Slider + Positioned( + right: 16, + top: MediaQuery.of(context).size.height * 0.25, + child: GestureDetector( + onVerticalDragUpdate: (details) async { + // Throttling (30ms) + final now = DateTime.now(); + if (_lastExposureUpdate != null && now.difference(_lastExposureUpdate!).inMilliseconds < 30) { + return; + } + + // Drag Up = Brighter (+), Down = Darker (-) + final sensitivity = 0.03; + final delta = -details.delta.dy * sensitivity; + + if (_controller == null || !_controller!.value.isInitialized) return; + + final newValue = (_currentExposureOffset + delta).clamp(_minExposure, _maxExposure); + + // UI immediate update + setState(() => _currentExposureOffset = newValue); + // Async camera update + _setExposureSafe(newValue); + + _lastExposureUpdate = now; + }, + onVerticalDragEnd: (details) { + // Finalize value on drag end + _setExposureSafe(_currentExposureOffset); + }, + onDoubleTap: () async { + // Reset + if (_controller == null) return; + setState(() => _currentExposureOffset = 0.0); + _setExposureSafe(0.0); + }, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Sun Icon (Bright) + Icon(LucideIcons.sun, color: _currentExposureOffset > 0.5 ? Colors.yellow : Colors.white54, size: 24), + const SizedBox(height: 8), + + // Vertical Track + Container( + height: 180, + width: 4, // Thin track + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.3), + borderRadius: BorderRadius.circular(2), + ), + child: Stack( + alignment: Alignment.bottomCenter, + children: [ + // Center Marker + Align(alignment: Alignment.center, child: Container(height: 2, width: 12, color: Colors.white54)), + + // Knob + LayoutBuilder( + builder: (context, constraints) { + final range = _maxExposure - _minExposure; + if (range == 0) return const SizedBox(); + + final normalized = (_currentExposureOffset - _minExposure) / range; + // 1.0 is top (max), 0.0 is bottom (min) + final topPos = constraints.maxHeight * (1 - normalized) - 10; // -HalfKnob + + return Positioned( + top: topPos.clamp(0, constraints.maxHeight - 20), + child: Container( + width: 18, + height: 18, + decoration: BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, + boxShadow: [BoxShadow(color: Colors.black26, blurRadius: 4)], + ), + ), + ); + } + ), + ], + ), + ), + const SizedBox(height: 8), + + // Moon Icon (Dark) + Icon(LucideIcons.moon, color: _currentExposureOffset < -0.5 ? Colors.blue[200] : Colors.white54, size: 20), + + // Value Text + if (_currentExposureOffset.abs() > 0.1) + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text( + _currentExposureOffset > 0 ? '+${_currentExposureOffset.toStringAsFixed(1)}' : _currentExposureOffset.toStringAsFixed(1), + style: const TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.bold, shadows: [Shadow(color: Colors.black, blurRadius: 2)]), + ), + ), + ], + ), + ), + ), + + // Overlay UI + SafeArea( + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + // Top Bar + Padding( + padding: const EdgeInsets.all(8.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + IconButton( + icon: const Icon(LucideIcons.x, color: Colors.white, size: 32), + onPressed: () => Navigator.of(context).pop(), + ), + // iOS-style Zoom Buttons + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), + decoration: BoxDecoration( + color: Colors.black.withOpacity(0.6), + borderRadius: BorderRadius.circular(20), + border: Border.all(color: Colors.white.withOpacity(0.3), width: 1), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + _buildZoomButton('1.0', 1.0), + const SizedBox(width: 8), + _buildZoomButton('2.0', 2.0), + const SizedBox(width: 8), + _buildZoomButton('3.0', 3.0), + ], + ), + ), + ], + ), + ), + // Shutter Button + Padding( + padding: const EdgeInsets.only(bottom: 32.0), + child: GestureDetector( + onTap: _takePicture, + child: Container( + height: 80, + width: 80, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: _quotaLockoutTime != null ? Colors.red : Colors.white, + width: 4 + ), + color: _isTakingPicture + ? Colors.white.withValues(alpha: 0.5) + : (_quotaLockoutTime != null ? Colors.red.withValues(alpha: 0.2) : Colors.transparent), + ), + child: Center( + child: _quotaLockoutTime != null + ? const Icon(LucideIcons.timer, color: Colors.white, size: 30) + : Container( + height: 60, + width: 60, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: _quotaLockoutTime != null ? Colors.grey : Colors.white, + ), + ), + ), + ), + ), + ), + ], + ), + ), + if (_isTakingPicture) + Container( + color: Colors.black.withValues(alpha: 0.5), + child: const Center( + child: CircularProgressIndicator(), + ), + ), + ], + ); + } else { + return const Center(child: CircularProgressIndicator()); + } + }, + ), + ); + } + + Widget _buildZoomButton(String label, double zoom) { + // Current Zoom Logic: Highlight if close + final isActive = (_currentZoom - zoom).abs() < 0.3; + + return GestureDetector( + onTap: () async { + if (_controller == null || !_controller!.value.isInitialized) return; + final targetZoom = zoom.clamp(_minZoom, _maxZoom); + + try { + await _controller!.setZoomLevel(targetZoom); + setState(() => _currentZoom = targetZoom); + } catch (e) { + debugPrint('Zoom error: $e'); + } + }, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: isActive ? Colors.white : Colors.transparent, + borderRadius: BorderRadius.circular(16), + ), + child: Text( + label, + style: TextStyle( + color: isActive ? Colors.black : Colors.white, + fontSize: 14, + fontWeight: isActive ? FontWeight.bold : FontWeight.normal, + ), + ), + ), + ); + } +} + diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart new file mode 100644 index 0000000..16d5b76 --- /dev/null +++ b/lib/screens/home_screen.dart @@ -0,0 +1,507 @@ +import 'package:flutter/cupertino.dart'; // CupertinoPicker +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../providers/theme_provider.dart'; +import '../providers/display_mode_provider.dart'; +import 'camera_screen.dart'; +import 'sake_detail_screen.dart'; +import 'menu_pricing_screen.dart'; +import 'menu_creation_screen.dart'; +import '../theme/app_theme.dart'; + +import 'dart:io'; +import '../providers/sake_list_provider.dart'; +import '../providers/filter_providers.dart'; +import '../providers/menu_providers.dart'; // Phase 2-1 +import '../models/sake_item.dart'; + +import 'package:image_picker/image_picker.dart'; +import 'package:flutter/services.dart'; // Haptic +import '../services/gemini_service.dart'; +import '../services/image_compression_service.dart'; +import '../widgets/analyzing_dialog.dart'; +import '../widgets/quota_warning_dialog.dart'; +import 'package:hive_flutter/hive_flutter.dart'; +import 'package:uuid/uuid.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:path/path.dart' show join; +import 'package:reorderable_grid_view/reorderable_grid_view.dart'; +import '../widgets/sake_search_delegate.dart'; +import '../widgets/onboarding_dialog.dart'; +import '../widgets/home/sake_filter_chips.dart'; +import '../widgets/home/home_empty_state.dart'; +import '../widgets/home/sake_no_match_state.dart'; +import '../widgets/home/sake_list_view.dart'; +import '../widgets/home/sake_grid_view.dart'; +import '../widgets/add_set_item_dialog.dart'; +import 'package:flutter_speed_dial/flutter_speed_dial.dart'; +import 'package:lucide_icons/lucide_icons.dart'; +import '../widgets/prefecture_filter_sheet.dart'; + +// Use a simple global variable for session check instead of StateProvider to avoid dependency issues +bool _hasCheckedOnboarding = false; + +class HomeScreen extends ConsumerWidget { + const HomeScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final fontPref = ref.watch(fontPreferenceProvider); + final displayMode = ref.watch(displayModeProvider); + final sakeListAsync = ref.watch(sakeListProvider); + + // Onboarding Check (Run once per session) + if (!_hasCheckedOnboarding) { + Future.microtask(() { + final profile = ref.read(userProfileProvider); + if (!profile.hasCompletedOnboarding) { + _showOnboardingDialog(context, ref); + } + _hasCheckedOnboarding = true; + }); + } + + // Filter States + final isSearching = ref.watch(sakeSearchQueryProvider).isNotEmpty; + final showFavorites = ref.watch(sakeFilterFavoriteProvider); + final selectedTag = ref.watch(sakeFilterTagProvider); + final selectedPrefecture = ref.watch(sakeFilterPrefectureProvider); + + final isMenuMode = ref.watch(menuModeProvider); + final userProfile = ref.watch(userProfileProvider); + final isBusinessMode = userProfile.isBusinessMode; + + return Scaffold( + appBar: AppBar( + title: isMenuMode + ? const Text('お品書き作成', style: TextStyle(fontWeight: FontWeight.bold)) + : (isSearching + ? Row( + children: [ + Expanded( + child: TextField( + autofocus: true, + decoration: const InputDecoration( + hintText: '銘柄・酒蔵・都道府県...', + border: InputBorder.none, + hintStyle: TextStyle(color: Colors.white70), + ), + style: const TextStyle(color: Colors.white), + onChanged: (value) => ref.read(sakeSearchQueryProvider.notifier).set(value), + ), + ), + // Sort Button (Searching State) + IconButton( + icon: const Icon(LucideIcons.arrowUpDown), + tooltip: '並び替え', + onPressed: () => _showSortMenu(context, ref), + ), + ], + ) + : null), + actions: [ + if (false) ...[ + // Menu Mode Actions + ] else ...[ + // Normal Actions + if (isBusinessMode) + IconButton( + icon: const Icon(LucideIcons.plus), + tooltip: 'セット商品を追加', + onPressed: () { + showDialog( + context: context, + builder: (context) => const AddSetItemDialog(), + ); + }, + ), + + if (!isSearching) // Show Sort button here if not searching + IconButton( + icon: const Icon(LucideIcons.arrowUpDown), + tooltip: '並び替え', + onPressed: () => _showSortMenu(context, ref), + ), + + IconButton( + icon: const Icon(LucideIcons.search), + onPressed: () => showSearch(context: context, delegate: SakeSearchDelegate(ref)), + ), + // ... rest of icons (Location, Favorite, DisplayMode, Guide) + IconButton( + icon: const Icon(LucideIcons.mapPin), + onPressed: () => PrefectureFilterSheet.show(context), + tooltip: '都道府県で絞り込み', + color: selectedPrefecture != null ? AppTheme.posimaiBlue : null, + ), + IconButton( + icon: Icon(showFavorites ? Icons.favorite : Icons.favorite_border), + color: showFavorites ? Colors.pink : null, + onPressed: () => ref.read(sakeFilterFavoriteProvider.notifier).toggle(), + tooltip: 'Favorites Only', + ), + IconButton( + icon: Icon(displayMode == 'list' ? LucideIcons.layoutGrid : LucideIcons.list), + onPressed: () => ref.read(displayModeProvider.notifier).toggle(), + ), + IconButton( + icon: const Icon(LucideIcons.helpCircle), + onPressed: () => _showUsageGuide(context, ref), + tooltip: 'ヘルプ・ガイド', + ), + ], + ], + ), + body: SafeArea( + child: Column( + children: [ + if (!isMenuMode) + if (!isMenuMode) + SakeFilterChips( + mode: isBusinessMode ? FilterChipMode.business : FilterChipMode.personal + ), + +// Menu Info Banner Removed + + Expanded( + child: sakeListAsync.when( + data: (sakeList) { + final showSelected = isMenuMode && ref.watch(menuShowSelectedOnlyProvider); + + List displayList; + if (showSelected) { + final orderedIds = ref.watch(menuOrderedIdsProvider); + // Map Ordered Ids to Objects. + // Note: O(N*M) if naive. Use Map for O(N). + final sakeMap = {for (var s in sakeList) s.id: s}; + displayList = orderedIds + .map((id) => sakeMap[id]) + .where((s) => s != null) + .cast() + .toList(); + } else { + displayList = sakeList; + } + + // Check if Global List is empty vs Filtered List is empty + final isListActuallyEmpty = ref.watch(rawSakeListItemsProvider).asData?.value.isEmpty ?? true; + + if (displayList.isEmpty) { + if (showSelected) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(LucideIcons.clipboardCheck, size: 60, color: Colors.grey[400]), + const SizedBox(height: 16), + const Text('お品書きに追加されたお酒はありません', style: TextStyle(fontWeight: FontWeight.bold)), + const SizedBox(height: 8), + const Text('リスト画面に戻って、掲載したいお酒の\nチェックボックスを選択してください', textAlign: TextAlign.center, style: TextStyle(color: Colors.grey)), + ], + ), + ); + } else if (isListActuallyEmpty) { + return const HomeEmptyState(); + } else { + return const SakeNoMatchState(); + } + } + + // Logic: Reorder only if Custom Sort is active (and not searching) + final sortMode = ref.watch(sakeSortModeProvider); + final isCustomSort = sortMode == SortMode.custom; + final canReorder = isCustomSort && !isSearching; // Menu mode doesn't support reorder + + return displayMode == 'list' + ? SakeListView(sakeList: displayList, isMenuMode: false, enableReorder: canReorder) + : SakeGridView(sakeList: displayList, isMenuMode: false, enableReorder: canReorder); + }, + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, st) => Center(child: Text('Error: $e')), + ), + ), + ], + ), + ), + floatingActionButton: isBusinessMode + ? SpeedDial( + icon: LucideIcons.plus, + activeIcon: LucideIcons.x, + backgroundColor: Colors.orange, + foregroundColor: Colors.white, + activeBackgroundColor: Colors.grey[800], + overlayColor: Colors.black, + overlayOpacity: 0.5, + spacing: 12, + spaceBetweenChildren: 12, + children: [ + SpeedDialChild( + child: const Text('🍶', style: TextStyle(fontSize: 24)), + backgroundColor: Colors.white, + label: 'お品書き作成', + labelStyle: const TextStyle(fontWeight: FontWeight.bold), + onTap: () { + Navigator.push( + context, + MaterialPageRoute(builder: (context) => const MenuCreationScreen()), + ); + }, + ), + SpeedDialChild( + child: const Icon(LucideIcons.packagePlus, color: Colors.orange), + backgroundColor: Colors.white, + label: 'セット商品を追加', + labelStyle: const TextStyle(fontWeight: FontWeight.bold), + onTap: () { + showDialog( + context: context, + builder: (context) => const AddSetItemDialog(), + ); + }, + ), + SpeedDialChild( + child: const Icon(LucideIcons.image, color: AppTheme.posimaiBlue), + backgroundColor: Colors.white, + label: '画像の読み込み', + labelStyle: const TextStyle(fontWeight: FontWeight.bold), + onTap: () async { + HapticFeedback.heavyImpact(); + await _pickFromGallery(context); + }, + ), + SpeedDialChild( + child: const Icon(LucideIcons.camera, color: AppTheme.posimaiBlue), + backgroundColor: Colors.white, + label: '商品を撮影', + labelStyle: const TextStyle(fontWeight: FontWeight.bold), + onTap: () { + Navigator.of(context).push( + MaterialPageRoute(builder: (context) => const CameraScreen()), + ); + }, + ), + ], + ) + : GestureDetector( + onLongPress: () async { + HapticFeedback.heavyImpact(); + await _pickFromGallery(context); + }, + child: FloatingActionButton( + onPressed: () { + Navigator.of(context).push( + MaterialPageRoute(builder: (context) => const CameraScreen()), + ); + }, + backgroundColor: AppTheme.posimaiBlue, + foregroundColor: Colors.white, + child: const Icon(LucideIcons.camera), + ), + ), + floatingActionButtonLocation: FloatingActionButtonLocation.endFloat, + ); + } + + Future _pickFromGallery(BuildContext context) async { + final picker = ImagePicker(); + final XFile? image = await picker.pickImage(source: ImageSource.gallery); + + if (image != null && context.mounted) { + _processImage(context, image.path); + } + } + + Future _processImage(BuildContext context, String sourcePath) async { + try { + // Show AnalyzingDialog immediately + showDialog( + context: context, + barrierDismissible: false, + builder: (context) => const AnalyzingDialog(), + ); + + // Compress and copy image to app docs + final directory = await getApplicationDocumentsDirectory(); + final String targetPath = join(directory.path, '${const Uuid().v4()}.jpg'); + final String imagePath = await ImageCompressionService.compressForGemini(sourcePath, targetPath: targetPath); + + // Gemini Analysis + final geminiService = GeminiService(); + final result = await geminiService.analyzeSakeLabel([imagePath]); + + // Create SakeItem + // Create SakeItem (Schema v2.0) + final sakeItem = SakeItem( + id: const Uuid().v4(), + displayData: DisplayData( + name: result.name ?? '不明な日本酒', + brewery: result.brand ?? '不明', + prefecture: result.prefecture ?? '不明', + catchCopy: result.catchCopy, + imagePaths: [imagePath], + rating: null, + ), + hiddenSpecs: HiddenSpecs( + description: result.description, + tasteStats: result.tasteStats, + flavorTags: result.flavorTags, + ), + metadata: Metadata( + createdAt: DateTime.now(), + aiConfidence: result.confidenceScore, + ), + ); + + // Save to Hive + final box = Hive.box('sake_items'); + await box.add(sakeItem); + + // Prepend new item to sort order so it appears at the top + final settingsBox = Hive.box('settings'); + final List currentOrder = (settingsBox.get('sake_sort_order') as List?) + ?.cast() ?? []; + currentOrder.insert(0, sakeItem.id); // Insert at beginning + await settingsBox.put('sake_sort_order', currentOrder); + + if (!context.mounted) return; + + // Close Dialog + Navigator.of(context).pop(); + + // Success Message + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('${sakeItem.displayData.name} を登録しました!'), + duration: const Duration(seconds: 2), + ), + ); + + } catch (e) { + if (context.mounted) { + // Attempt to pop dialog if it's open (this is heuristic, better state mgmt would be ideal) + // But for now, we assume top is dialog. + Navigator.of(context).pop(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('解析エラー: $e')), + ); + } + } + } + + + + void _showOnboardingDialog(BuildContext context, WidgetRef ref) { + showDialog( + context: context, + barrierDismissible: false, + builder: (ctx) => OnboardingDialog( + onFinish: () { + Navigator.pop(ctx); + ref.read(userProfileProvider.notifier).completeOnboarding(); + }, + ), + ); + } + + void _showUsageGuide(BuildContext context, WidgetRef ref) { + final userProfile = ref.read(userProfileProvider); + final isBusinessMode = userProfile.isBusinessMode; + + List>? pages; + + if (isBusinessMode) { + pages = [ + { + 'title': 'ビジネスモードへようこそ', + 'description': '飲食店様向けの機能を集約しました。\n在庫管理からメニュー作成まで、\nプロの仕事を強力にサポートします。', + 'icon': '💼', + }, + { + 'title': 'セット商品の作成', + 'description': '飲み比べセットやコース料理など、\n複数のお酒をまとめた「セット商品」を\n簡単に作成・管理できます。', + 'icon': '🍱', + }, + { + 'title': '販促ツール(インスタ)', + 'description': '本日のおすすめをSNSですぐに発信。\nInstaタブから、美しい画像を\nワンタップで生成できます。', + 'icon': '📸', + }, + { + 'title': '高度な分析', + 'description': '売れ筋や味の傾向を分析。\nお客様に喜ばれるラインナップ作りを\nデータで支援します。', + 'icon': '📊', + }, + ]; + } + + showDialog( + context: context, + barrierDismissible: true, + builder: (ctx) => OnboardingDialog( + pages: pages, + onFinish: () => Navigator.pop(ctx), + ), + ); + } + + + + void _showSortMenu(BuildContext context, WidgetRef ref) { + final currentSort = ref.read(sakeSortModeProvider); + + showModalBottomSheet( + context: context, + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(16))), + builder: (context) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Padding( + padding: EdgeInsets.symmetric(vertical: 16), + child: Text('並び替え', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18)), + ), + ListTile( + leading: const Icon(LucideIcons.clock), + title: const Text('新しい順(登録日)'), + trailing: currentSort == SortMode.newest ? Icon(LucideIcons.check, color: AppTheme.posimaiBlue) : null, + onTap: () { + ref.read(sakeSortModeProvider.notifier).set(SortMode.newest); + Navigator.pop(context); + }, + ), + ListTile( + leading: const Icon(LucideIcons.history), + title: const Text('古い順(登録日)'), + trailing: currentSort == SortMode.oldest ? Icon(LucideIcons.check, color: AppTheme.posimaiBlue) : null, + onTap: () { + ref.read(sakeSortModeProvider.notifier).set(SortMode.oldest); + Navigator.pop(context); + }, + ), + ListTile( + leading: const Icon(LucideIcons.arrowDownAZ), + title: const Text('名前順(あいうえお)'), + trailing: currentSort == SortMode.name ? Icon(LucideIcons.check, color: AppTheme.posimaiBlue) : null, + onTap: () { + ref.read(sakeSortModeProvider.notifier).set(SortMode.name); + Navigator.pop(context); + }, + ), + ListTile( + leading: const Icon(LucideIcons.gripHorizontal), + title: const Text('カスタム(ドラッグ配置)'), + trailing: currentSort == SortMode.custom ? Icon(LucideIcons.check, color: AppTheme.posimaiBlue) : null, + onTap: () { + ref.read(sakeSortModeProvider.notifier).set(SortMode.custom); + Navigator.pop(context); + }, + ), + const SizedBox(height: 16), + ], + ), + ), + ); + } + +// Method _buildBusinessQuickFilters removed (Using SakeFilterChips instead) +} // End of HomeScreen class diff --git a/lib/screens/main_screen.dart b/lib/screens/main_screen.dart new file mode 100644 index 0000000..3a13da7 --- /dev/null +++ b/lib/screens/main_screen.dart @@ -0,0 +1,90 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:lucide_icons/lucide_icons.dart'; +import '../providers/theme_provider.dart'; // Access userProfileProvider +import 'home_screen.dart'; +import 'soul_screen.dart'; +import 'shop_settings_screen.dart'; +import 'placeholders/placeholders.dart'; + +class MainScreen extends ConsumerStatefulWidget { + const MainScreen({super.key}); + + @override + ConsumerState createState() => _MainScreenState(); +} + +class _MainScreenState extends ConsumerState { + int _currentIndex = 0; + + @override + Widget build(BuildContext context) { + // Listen for mode changes to reset navigation to Home + ref.listen(userProfileProvider.select((value) => value.isBusinessMode), (previous, next) { + if (previous != next) { + setState(() { + _currentIndex = 0; + }); + } + }); + + final userProfile = ref.watch(userProfileProvider); + final isBusiness = userProfile.isBusinessMode; + + // Define Screens for each mode + final List screens = isBusiness + ? [ + const HomeScreen(), // Inventory Management (FAB opens Menu Creation) + const InstaSupportScreen(), // Social Media Support + const AnalyticsScreen(), // Analytics + const ShopSettingsScreen(), // Shop Settings + ] + : [ + const HomeScreen(), // My Sake List + const ScanARScreen(), + const SommelierScreen(), + const BreweryMapScreen(), + const SoulScreen(), // MyPage/Settings + ]; + + // Define Navigation Items + final List destinations = isBusiness + ? const [ + NavigationDestination(icon: Icon(LucideIcons.package), label: '在庫'), + NavigationDestination(icon: Icon(LucideIcons.instagram), label: '販促'), + NavigationDestination(icon: Icon(LucideIcons.barChart), label: '分析'), + NavigationDestination(icon: Icon(LucideIcons.store), label: '店舗'), + ] + : const [ + NavigationDestination( + icon: Padding(padding: EdgeInsets.only(bottom: 2), child: Text('🍶', style: TextStyle(fontSize: 22))), + label: 'ホーム', + ), + NavigationDestination(icon: Icon(LucideIcons.scanLine), label: 'スキャン'), + NavigationDestination(icon: Icon(LucideIcons.sparkles), label: 'ソムリエ'), + NavigationDestination(icon: Icon(LucideIcons.map), label: 'マップ'), + NavigationDestination(icon: Icon(LucideIcons.user), label: 'マイページ'), + ]; + + // Safety: Reset index if out of bounds (shouldn't happen if lengths match) + if (_currentIndex >= screens.length) _currentIndex = 0; + + return Scaffold( + body: SafeArea( + child: IndexedStack( + index: _currentIndex, + children: screens, + ), + ), + bottomNavigationBar: NavigationBar( + selectedIndex: _currentIndex, + onDestinationSelected: (index) { + setState(() { + _currentIndex = index; + }); + }, + destinations: destinations, + ), + ); + } +} diff --git a/lib/screens/menu_creation_screen.dart b/lib/screens/menu_creation_screen.dart new file mode 100644 index 0000000..5c353d2 --- /dev/null +++ b/lib/screens/menu_creation_screen.dart @@ -0,0 +1,241 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/services.dart'; +import '../providers/sake_list_provider.dart'; +import '../providers/menu_providers.dart'; +import '../providers/display_mode_provider.dart'; +import '../providers/filter_providers.dart'; +import '../models/sake_item.dart'; +import '../widgets/home/home_empty_state.dart'; +import '../widgets/home/sake_no_match_state.dart'; +import '../widgets/home/sake_list_view.dart'; +import '../widgets/home/sake_grid_view.dart'; +import '../widgets/step_indicator.dart'; +import '../theme/app_theme.dart'; +import 'menu_pricing_screen.dart'; +import '../widgets/sake_search_delegate.dart'; +import '../widgets/prefecture_filter_sheet.dart'; + +class MenuCreationScreen extends ConsumerWidget { + const MenuCreationScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + // Force MenuMode provider for compatibility with child widgets (like SakeListItem) + // We should probably just pass `isMenuMode: true` to children, but SakeFilterChips might rely on it? + // SakeFilterChips doesn't use isMenuMode. + // SakeListItem/GridItem take `isMenuMode` as param. + // So we don't necessarily need to set the global provider, but it's safer if other widgets rely on it. + // Actually, `HomeScreen` toggles it. Here we are always in "Creation Mode". + + final displayMode = ref.watch(displayModeProvider); + final sakeListAsync = ref.watch(sakeListProvider); + final isSearching = ref.watch(sakeSearchQueryProvider).isNotEmpty; + final showSelectedOnly = ref.watch(menuShowSelectedOnlyProvider); + final showFavorites = ref.watch(sakeFilterFavoriteProvider); + final selectedPrefecture = ref.watch(sakeFilterPrefectureProvider); + + return Scaffold( + appBar: AppBar( + title: const StepIndicator(currentStep: 1, totalSteps: 3), + centerTitle: true, + automaticallyImplyLeading: false, // ヘッダー戻るボタンを無効化 + actions: [ + // Search (最も使用頻度が高い) + IconButton( + icon: const Icon(Icons.search), + onPressed: () => showSearch(context: context, delegate: SakeSearchDelegate(ref)), + tooltip: '検索', + ), + // Prefecture Filter + IconButton( + icon: const Icon(Icons.location_on), + onPressed: () => PrefectureFilterSheet.show(context), + tooltip: '都道府県で絞り込み', + color: selectedPrefecture != null ? AppTheme.posimaiBlue : null, + ), + // Show Selected Only Toggle (フィルターアイコンに変更) + IconButton( + icon: Icon(showSelectedOnly ? Icons.filter_list : Icons.filter_list_off), + color: showSelectedOnly ? AppTheme.posimaiBlue : null, + tooltip: showSelectedOnly ? '全て表示' : '選択中のみ表示', + onPressed: () { + if (!showSelectedOnly) { + // Initialize order when switching to "Selected Only" + final selected = ref.read(selectedMenuSakeIdsProvider); + if (selected.isNotEmpty) { + ref.read(menuOrderedIdsProvider.notifier).initialize(selected.toList()); + } + } + ref.read(menuShowSelectedOnlyProvider.notifier).toggle(); + }, + ), + ], + bottom: PreferredSize( + preferredSize: const Size.fromHeight(2), + child: LinearProgressIndicator( + value: 1 / 3, // Step 1 of 3 = 33% + backgroundColor: Colors.grey[200], + valueColor: const AlwaysStoppedAnimation(AppTheme.posimaiBlue), + minHeight: 2, + ), + ), + ), + body: Column( + children: [ + // Guide Banner (条件付き表示: 未選択時のみ) + () { + final selectedIds = ref.watch(selectedMenuSakeIdsProvider); + if (selectedIds.isEmpty && !showSelectedOnly) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.orange.withOpacity(0.1), + border: Border( + bottom: BorderSide(color: Colors.orange.withOpacity(0.3)), + ), + ), + child: Row( + children: [ + Icon(Icons.touch_app, size: 20, color: Colors.orange[700]), + const SizedBox(width: 8), + Expanded( + child: Text( + 'カードをタップして選択', + style: TextStyle( + color: Colors.orange[900], + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + ); + } + return const SizedBox.shrink(); + }(), + + // Main Content + Expanded( + child: sakeListAsync.when( + data: (sakeList) { + List displayList; + if (showSelectedOnly) { + final orderedIds = ref.watch(menuOrderedIdsProvider); + final sakeMap = {for (var s in sakeList) s.id: s}; + displayList = orderedIds + .map((id) => sakeMap[id]) + .where((s) => s != null) + .cast() + .toList(); + } else { + displayList = sakeList; + } + + final isListActuallyEmpty = ref.watch(rawSakeListItemsProvider).asData?.value.isEmpty ?? true; + + // Empty State Logic + if (displayList.isEmpty) { + if (showSelectedOnly) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.checklist, size: 60, color: Colors.grey[400]), + const SizedBox(height: 16), + const Text('お品書きに追加されたお酒はありません', style: TextStyle(fontWeight: FontWeight.bold)), + const SizedBox(height: 8), + const Text('「全て表示」に切り替えて\n掲載したいお酒を選択してください', textAlign: TextAlign.center, style: TextStyle(color: Colors.grey)), + ], + ), + ); + } else if (isListActuallyEmpty) { + return const HomeEmptyState(); + } else { + return const SakeNoMatchState(); + } + } + + return displayMode == 'list' + ? SakeListView(sakeList: displayList, isMenuMode: true, enableReorder: false) + : SakeGridView(sakeList: displayList, isMenuMode: true, enableReorder: false); + }, + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, st) => Center(child: Text('Error: $e')), + ), + ), + ], + ), + bottomNavigationBar: SafeArea( + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Theme.of(context).cardColor, + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 8, + offset: const Offset(0, -2), + ), + ], + ), + child: Row( + children: [ + // 戻るボタン (左端) + SizedBox( + width: 56, + height: 56, + child: OutlinedButton( + onPressed: () => Navigator.of(context).pop(), + style: OutlinedButton.styleFrom( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + side: BorderSide(color: Colors.grey[300]!), + padding: EdgeInsets.zero, + ), + child: Icon(Icons.arrow_back, color: Colors.grey[700]), + ), + ), + const SizedBox(width: 12), + + // 次へボタン (右側いっぱいに広がる) + Expanded( + child: SizedBox( + height: 56, + child: ElevatedButton.icon( + onPressed: () { + final selectedIds = ref.read(selectedMenuSakeIdsProvider); + if (selectedIds.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('お酒を選択してください')) + ); + return; + } + Navigator.push( + context, + MaterialPageRoute(builder: (context) => const MenuPricingScreen()), + ); + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.posimaiBlue, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + icon: const Icon(Icons.arrow_forward), + label: const Text('価格設定', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), + ), + ), + ), + ], + ), + ), + ), + ); + } + + +} diff --git a/lib/screens/menu_pricing_screen.dart b/lib/screens/menu_pricing_screen.dart new file mode 100644 index 0000000..68c8d4e --- /dev/null +++ b/lib/screens/menu_pricing_screen.dart @@ -0,0 +1,635 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../models/sake_item.dart'; +import '../providers/sake_list_provider.dart'; +import '../providers/menu_providers.dart'; +import 'package:hive_flutter/hive_flutter.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'menu_settings_screen.dart'; +import '../widgets/sake_price_dialog.dart'; +import '../widgets/step_indicator.dart'; +import '../services/pricing_helper.dart'; +import '../theme/app_theme.dart'; + +class MenuPricingScreen extends ConsumerStatefulWidget { + const MenuPricingScreen({super.key}); + + @override + ConsumerState createState() => _MenuPricingScreenState(); +} + +class _MenuPricingScreenState extends ConsumerState { + // Local price state (銘柄ID → 価格) + final Map _prices = {}; + // Local variants state (銘柄ID → バリエーションMap) + final Map> _variants = {}; + + @override + void initState() { + super.initState(); + // One-time hint for Exit button + WidgetsBinding.instance.addPostFrameCallback((_) { + final prefs = SharedPreferences.getInstance(); + final shown = prefs.then((p) => p.getBool('business_mode_help_shown') ?? false); + + shown.then((hasShown) { + if (!hasShown && mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('右上の×でいつでも終了できます'), + duration: const Duration(seconds: 3), + action: SnackBarAction( + label: 'OK', + onPressed: () {}, + ), + ), + ); + prefs.then((p) => p.setBool('business_mode_help_shown', true)); + } + }); + }); + } + + @override + Widget build(BuildContext context) { + // Initialize orderedIds if empty + final orderedIds = ref.watch(menuOrderedIdsProvider); + final selectedIds = ref.watch(selectedMenuSakeIdsProvider); + final sakeListAsync = ref.watch(sakeListProvider); + + // Sync order with selection if needed + // We use a post-frame callback or check during build to initialize if empty relative to selection + // But modifying provider during build is bad. + // Better to just derive the list for display if orderedIds is empty, + // AND initialize the provider when the user actually interacts (reorder) OR in initState. + + // Actually, we should initialize it in initState or via a ProviderListener. + // Let's do it in the build via a microtask if empty, OR safer: in initState. + + // Get selected items in order + final selectedItems = sakeListAsync.when( + data: (list) { + // Filter list first + final selectedList = list.where((item) => selectedIds.contains(item.id)).toList(); + + if (orderedIds.isNotEmpty) { + final sakeMap = {for (var s in list) s.id: s}; + // Return ordered items + any new selected items appended at the end + final orderedItems = orderedIds + .map((id) => sakeMap[id]) + .whereType() + .where((s) => selectedIds.contains(s.id)) + .toList(); + + // Append any selected items that are NOT in orderedIds (newly selected) + final orderedIdSet = orderedIds.toSet(); + final newItems = selectedList.where((s) => !orderedIdSet.contains(s.id)); + + return [...orderedItems, ...newItems]; + } else { + return selectedList; + } + }, + loading: () => [], + error: (_, __) => [], + ); + + // Initialize prices from existing data + for (var item in selectedItems) { + if (!_prices.containsKey(item.id)) { + // Use manualPrice or calculated price as initial value + _prices[item.id] = item.userData.price; + } + if (!_variants.containsKey(item.id) && item.userData.priceVariants != null) { + _variants[item.id] = Map.from(item.userData.priceVariants!); + } + } + + final setPricesCount = _prices.values.where((p) => p != null && p > 0).length; + + return Scaffold( + appBar: AppBar( + automaticallyImplyLeading: false, + title: const StepIndicator(currentStep: 2, totalSteps: 3), + centerTitle: true, + actions: [ + IconButton( + icon: const Icon(Icons.close), + onPressed: () => _showExitDialog(context, ref), + tooltip: '終了', + ), + ], + bottom: PreferredSize( + preferredSize: const Size.fromHeight(2), + child: LinearProgressIndicator( + value: 2 / 3, // Step 2 of 3 = 66% + backgroundColor: Colors.grey[200], + valueColor: const AlwaysStoppedAnimation(AppTheme.posimaiBlue), + minHeight: 2, + ), + ), + ), + body: selectedItems.isEmpty + ? const Center(child: Text('お酒が選択されていません')) + : Column( + children: [ + // ガイドバナー (銘柄選択画面と統一) + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.blue.withOpacity(0.1), + border: Border( + bottom: BorderSide(color: Colors.blue.withOpacity(0.3)), + ), + ), + child: Row( + children: [ + Icon(Icons.swap_vert, size: 20, color: Colors.blue[700]), + const SizedBox(width: 8), + Expanded( + child: Text( + 'ドラッグして並び替え', + style: TextStyle( + color: Colors.blue[900], + fontWeight: FontWeight.bold, + ), + ), + ), + TextButton( + onPressed: () => _showBulkPriceDialog(selectedItems), + style: TextButton.styleFrom( + foregroundColor: AppTheme.posimaiBlue, + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 0), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + child: const Text('一括設定', style: TextStyle(fontWeight: FontWeight.bold)), + ), + ], + ), + ), + + // Scrollable List + Expanded( + child: ReorderableListView.builder( + padding: const EdgeInsets.all(16), + itemCount: selectedItems.length, + onReorder: (int oldIndex, int newIndex) { + if (oldIndex < newIndex) { + newIndex -= 1; + } + + // CRITICALLY IMPORTANT: + // Ensure the provider is initialized with the current view's IDs before reordering + // if it was empty or out of sync. + final currentIds = selectedItems.map((s) => s.id).toList(); + final notifier = ref.read(menuOrderedIdsProvider.notifier); + + // Check if we need to initialize or just reorder + // If the provider state suggests it's empty or mismatch, force init first. + if (ref.read(menuOrderedIdsProvider).isEmpty || ref.read(menuOrderedIdsProvider).length != currentIds.length) { + notifier.initialize(currentIds); + } + + notifier.reorder(oldIndex, newIndex); + }, + itemBuilder: (context, index) { + final sake = selectedItems[index]; + // Wrap in Keyed Subtree via Container/Padding with Key + return Padding( + key: ValueKey(sake.id), // CRITICAL for ReorderableListView + padding: const EdgeInsets.only(bottom: 8), + child: _buildPriceCard(sake, index), // Pass index for potential future use or just context + ); + }, + ), + ), + + // Bottom Action Bar (統一デザイン) + SafeArea( + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Theme.of(context).cardColor, + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 8, + offset: const Offset(0, -2), + ), + ], + ), + child: Row( + children: [ + // 戻るボタン (左端) + SizedBox( + width: 56, + height: 56, + child: OutlinedButton( + onPressed: () => Navigator.pop(context), + style: OutlinedButton.styleFrom( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + side: BorderSide(color: Colors.grey[300]!), + padding: EdgeInsets.zero, + ), + child: Icon(Icons.arrow_back, color: Colors.grey[700]), + ), + ), + const SizedBox(width: 12), + + // 次へボタン (右側いっぱいに広がる) + Expanded( + child: SizedBox( + height: 56, + child: ElevatedButton.icon( + onPressed: setPricesCount == selectedItems.length + ? () => _proceedToMenuSettings(selectedItems) + : null, + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.posimaiBlue, + foregroundColor: Colors.white, + disabledBackgroundColor: Colors.grey[300], + disabledForegroundColor: Colors.grey[600], + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + icon: const Icon(Icons.arrow_forward), + label: Text( + setPricesCount == selectedItems.length + ? '表示設定' + : '価格を設定してください', + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + ), + ), + ), + ), + ], + ), + ), + ), + ], + ), + ); + } + + Widget _buildPriceCard(SakeItem sake, int index) { + // Check if already set in sake data (from previous menu) + final existingPrice = sake.userData.price; + final currentPrice = _prices[sake.id] ?? existingPrice; + final hasPrice = currentPrice != null && currentPrice > 0; + final variants = _variants[sake.id] ?? {}; + + // Auto-load existing price if not yet set locally + if (_prices[sake.id] == null && existingPrice != null) { + _prices[sake.id] = existingPrice; + } + + return Card( + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: hasPrice + ? BorderSide(color: Colors.green, width: 2) // Green for ready + : const BorderSide(color: Colors.red, width: 2), // Red for missing + ), + child: InkWell( + borderRadius: BorderRadius.circular(12), + onTap: () => _showPriceDialog(sake), + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Sake Name + Row( + children: [ + // Drag Handle + ReorderableDragStartListener( + index: index, + child: const Padding( + padding: EdgeInsets.only(right: 12, top: 4, bottom: 4), + child: Icon(Icons.drag_indicator, color: Colors.grey), + ), + ), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + sake.displayData.name, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + if (sake.itemType != ItemType.set) + Text( + '${sake.displayData.brewery} / ${sake.displayData.prefecture}', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Colors.grey[600], + ), + ), + ], + ), + ), + Icon( + hasPrice ? Icons.check_circle : Icons.edit, + color: hasPrice ? Colors.green : Colors.grey, + ), + ], + ), + const SizedBox(height: 12), + + // Price Display + if (hasPrice) ...[ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.grey.withOpacity(0.05), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.grey.withOpacity(0.2)), + ), + child: variants.isEmpty + ? Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + '一合', + style: TextStyle(fontSize: 14, color: Colors.grey), + ), + Text( + '${PricingHelper.formatPrice(currentPrice!)}円', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + ], + ) + : Column( + children: variants.entries.map((e) => Padding( + padding: const EdgeInsets.only(bottom: 6), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + e.key, + style: const TextStyle(fontSize: 14, color: Colors.grey), + ), + Text( + '${PricingHelper.formatPrice(e.value)}円', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + )).toList(), + ), + ), + ] else ...[ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.red.withOpacity(0.05), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.red.withOpacity(0.3)), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.add_circle_outline, size: 20, color: Colors.red[700]), + const SizedBox(width: 8), + Text( + '価格を設定してください', + style: TextStyle( + color: Colors.red[700], + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + ], + ], + ), + ), + ), + ); + } + + /// 価格設定ダイアログを表示 + void _showPriceDialog(SakeItem sake) { + showDialog( + context: context, + builder: (context) => SakePriceDialog( + sakeItem: sake, + onSave: (basePrice, variants) { + setState(() { + _prices[sake.id] = basePrice; + _variants[sake.id] = variants; + }); + }, + ), + ); + } + + void _showBulkPriceDialog(List items) { + int? bulkPrice; + bool overwriteVariants = false; + + // Count items with multiple size variants + final variantsCount = items.where((item) { + final variants = _variants[item.id]; + return variants != null && variants.isNotEmpty; + }).length; + + showDialog( + context: context, + builder: (context) => StatefulBuilder( + builder: (context, setDialogState) => AlertDialog( + title: const Text('一括設定'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + keyboardType: TextInputType.number, + decoration: const InputDecoration( + labelText: '一合の税込価格', + hintText: '例: 1500', + suffixText: '円', + border: OutlineInputBorder(), + ), + autofocus: true, + onChanged: (value) { + bulkPrice = int.tryParse(value); + }, + ), + if (variantsCount > 0) ...[ + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.orange.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.orange.withValues(alpha: 0.3)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.warning_amber, color: Colors.orange[700], size: 20), + const SizedBox(width: 8), + Expanded( + child: Text( + '提供サイズ設定済み: $variantsCount銘柄', + style: TextStyle( + fontWeight: FontWeight.bold, + color: Colors.orange[900], + ), + ), + ), + ], + ), + const SizedBox(height: 4), + InkWell( + onTap: () { + setDialogState(() { + overwriteVariants = !overwriteVariants; + }); + }, + child: Row( + children: [ + SizedBox( + height: 24, + width: 24, + child: Checkbox( + value: overwriteVariants, + onChanged: (value) { + setDialogState(() { + overwriteVariants = value ?? false; + }); + }, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + visualDensity: VisualDensity.compact, + ), + ), + const SizedBox(width: 8), + const Expanded( + child: Text( + '一合の税込価格で上書き', + style: TextStyle(fontSize: 14), + ), + ), + ], + ), + ), + ], + ), + ), + ], + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('キャンセル'), + ), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.posimaiBlue, + foregroundColor: Colors.white, + ), + onPressed: () { + if (bulkPrice != null && bulkPrice! > 0) { + setState(() { + for (var item in items) { + final hasVariants = _variants[item.id] != null && _variants[item.id]!.isNotEmpty; + + // Skip items with variants unless overwrite is checked + if (hasVariants && !overwriteVariants) { + continue; + } + + _prices[item.id] = bulkPrice; + + // Clear variants if overwriting + if (hasVariants && overwriteVariants) { + _variants[item.id] = {}; + } + } + }); + Navigator.pop(context); + } + }, + child: const Text('適用'), + ), + ], + ), + ), + ); + } + + + Future _proceedToMenuSettings(List items) async { + // Save prices to Hive + final box = Hive.box('sake_items'); + + for (var item in items) { + final price = _prices[item.id]; + final variants = _variants[item.id]; + + if (price != null && price > 0) { + final newItem = item.copyWith( + manualPrice: price, + priceVariants: variants != null && variants.isNotEmpty ? variants : null, + isUserEdited: true, + ); + await box.put(item.key, newItem); + } + } + + if (!mounted) return; + + // Navigate to Menu Settings (simplified) + Navigator.push( + context, + MaterialPageRoute(builder: (context) => const MenuSettingsScreen()), + ); + } + + Future _showExitDialog(BuildContext context, WidgetRef ref) async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('お品書き作成を終了しますか?'), + content: const Text('入力内容は保存されません。'), + actions: [ + TextButton( + child: const Text('キャンセル'), + onPressed: () => Navigator.pop(context, false), + ), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.posimaiBlue, + foregroundColor: Colors.white, + ), + onPressed: () => Navigator.pop(context, true), + child: const Text('終了'), + ), + ], + ), + ); + + if (confirmed == true && context.mounted) { + ref.read(menuModeProvider.notifier).set(false); + ref.read(selectedMenuSakeIdsProvider.notifier).clear(); + Navigator.of(context).popUntil((route) => route.isFirst); + } + } +} diff --git a/lib/screens/menu_settings_screen.dart b/lib/screens/menu_settings_screen.dart new file mode 100644 index 0000000..3fa3391 --- /dev/null +++ b/lib/screens/menu_settings_screen.dart @@ -0,0 +1,561 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:intl/intl.dart'; +import 'package:flutter/cupertino.dart'; // For Rolling Picker +import 'pdf_preview_screen.dart'; +import 'package:hive_flutter/hive_flutter.dart'; +import '../providers/menu_providers.dart'; +import '../services/pdf_service.dart'; +import '../models/sake_item.dart'; +import '../models/menu_settings.dart'; +import '../providers/sake_list_provider.dart'; +import '../widgets/step_indicator.dart'; +import 'package:printing/printing.dart'; +import '../theme/app_theme.dart'; + +class MenuSettingsScreen extends ConsumerStatefulWidget { + const MenuSettingsScreen({super.key}); + + @override + ConsumerState createState() => _MenuSettingsScreenState(); +} + +class _MenuSettingsScreenState extends ConsumerState { + // Display options + bool includePhoto = true; + bool includePoem = true; + bool includeChart = true; + bool includePrice = true; + bool includeDate = true; + bool includeQr = false; // Default false for QR + + // PDF settings + String pdfSize = 'a4'; // 'a4', 'a5', 'b5' + bool isMonochrome = false; + + // TextEditingControllers + late final TextEditingController _titleController; + late final TextEditingController _dateController; // Stores date string + + @override + void initState() { + super.initState(); + _loadSettings(); + } + + // Load saved settings from Hive + void _loadSettings() { + final box = Hive.box('menu_settings'); + final savedSettings = box.get('current', defaultValue: MenuSettings()); + + // Initialize controllers first to avoid null reference errors + _titleController = TextEditingController(); + _dateController = TextEditingController( + text: DateFormat('yyyy年M月d日').format(DateTime.now()), + ); + + if (savedSettings != null) { + setState(() { + includePhoto = savedSettings.includePhoto; + includePoem = savedSettings.includePoem; + includeChart = savedSettings.includeChart; + includePrice = savedSettings.includePrice; + includeDate = savedSettings.includeDate; + includeQr = savedSettings.includeQr ?? false; // Handle migration safely if null + pdfSize = savedSettings.pdfSize; + isMonochrome = savedSettings.isMonochrome; + }); + + // Update controller text if saved settings exist + _titleController.text = savedSettings.title; + } + } + + // Save settings to Hive + Future _saveSettings() async { + final box = Hive.box('menu_settings'); + final settings = MenuSettings( + title: _titleController.text, + includePhoto: includePhoto, + includePoem: includePoem, + includeChart: includeChart, + includePrice: includePrice, + includeDate: includeDate, + includeQr: includeQr, + pdfSize: pdfSize, + isMonochrome: isMonochrome, + ); + await box.put('current', settings); + } + + @override + void dispose() { + _titleController.dispose(); + _dateController.dispose(); + super.dispose(); + } + + Future _showRollingDatePicker() async { + DateTime initialDate = DateTime.now(); + try { + // Try parsing current text, fallback to now + final format = DateFormat('yyyy年M月d日'); + initialDate = format.parse(_dateController.text); + } catch (_) {} + + await showCupertinoModalPopup( + context: context, + builder: (BuildContext context) => Container( + height: 216, + padding: const EdgeInsets.only(top: 6.0), + margin: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom, + ), + color: CupertinoColors.systemBackground.resolveFrom(context), + child: SafeArea( + top: false, + child: CupertinoDatePicker( + initialDateTime: initialDate, + mode: CupertinoDatePickerMode.date, + use24hFormat: true, + // Japanese locale logic if needed, but standard picker usually sufficient + onDateTimeChanged: (DateTime newDate) { + setState(() { + _dateController.text = DateFormat('yyyy年M月d日').format(newDate); + }); + _saveSettings(); + }, + ), + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + // Determine selected items and order + final selectedIds = ref.watch(selectedMenuSakeIdsProvider); + final orderedIds = ref.watch(menuOrderedIdsProvider); + final sakeListAsync = ref.watch(sakeListProvider); + + final selectedItems = sakeListAsync.when( + data: (list) { + if (orderedIds.isNotEmpty) { + final sakeMap = {for (var s in list) s.id: s}; + return orderedIds + .map((id) => sakeMap[id]) + .whereType() + .where((s) => selectedIds.contains(s.id)) + .toList(); + } else { + return list.where((item) => selectedIds.contains(item.id)).toList(); + } + }, + loading: () => [], + error: (_, __) => [], + ); + + return Scaffold( + appBar: AppBar( + automaticallyImplyLeading: false, + title: const StepIndicator(currentStep: 3, totalSteps: 3), + centerTitle: true, + actions: [ + IconButton( + icon: const Icon(Icons.close), + tooltip: '終了', + onPressed: () => _showExitDialog(context, ref), + ), + ], + bottom: PreferredSize( + preferredSize: const Size.fromHeight(2), + child: LinearProgressIndicator( + value: 1.0, // Step 3 of 3 = 100% + backgroundColor: Colors.grey[200], + valueColor: const AlwaysStoppedAnimation(AppTheme.posimaiBlue), + minHeight: 2, + ), + ), + ), + body: selectedItems.isEmpty + ? const Center(child: Text('お酒が選択されていません')) + : Column( + children: [ + Expanded( + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + // Preview List (Simple) + Text( + '選択中: ${selectedItems.length}銘柄', + style: Theme.of(context).textTheme.titleMedium, + ), + const Divider(), + ...selectedItems.map((sake) => ListTile( + leading: const Icon(Icons.check, size: 16), + title: Text(sake.displayData.name), + subtitle: Text('${sake.displayData.brewery} / ${sake.displayData.prefecture}'), + dense: true, + )).toList(), + + const Divider(height: 32), + + // Menu Attributes + Text( + 'お品書き情報', + style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 8), + TextField( + controller: _titleController, + decoration: const InputDecoration( + labelText: 'タイトル', + hintText: '日本酒リスト', + border: OutlineInputBorder(), + ), + onChanged: (value) => _saveSettings(), + ), + const SizedBox(height: 24), + + // Display Toggles with SwitchListTile + Text( + '表示項目', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + color: AppTheme.posimaiBlue, + ), + ), + const SizedBox(height: 8), + + Card( + elevation: 0, + color: Theme.of(context).colorScheme.surfaceContainerHighest.withOpacity(0.3), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: Column( + children: [ + SwitchListTile( + title: const Text('写真'), + value: includePhoto, + onChanged: (val) => setState(() { + includePhoto = val; + _saveSettings(); + }), + ), + const Divider(height: 1), + SwitchListTile( + title: const Text('キャッチコピー / 説明文'), + value: includePoem, + onChanged: (val) => setState(() { + includePoem = val; + _saveSettings(); + }), + ), + const Divider(height: 1), + SwitchListTile( + title: const Text('価格'), + value: includePrice, + onChanged: (val) => setState(() { + includePrice = val; + _saveSettings(); + }), + ), + const Divider(height: 1), + // QR Toggle + SwitchListTile( + title: const Text('QRコード (お持ち帰り用)'), + subtitle: const Text('アプリで読み取れる情報を埋め込みます', style: TextStyle(fontSize: 10, color: Colors.grey)), + value: includeQr, + onChanged: (val) => setState(() { + includeQr = val; + _saveSettings(); + }), + ), + const Divider(height: 1), + // Date Toggle in List + SwitchListTile( + title: const Text('日付'), + value: includeDate, + onChanged: (val) => setState(() { + includeDate = val; + _saveSettings(); + }), + ), + if (includeDate) ...[ + const Divider(height: 1), + ListTile( + leading: const Icon(Icons.calendar_today, size: 20), + title: Text(_dateController.text), + trailing: const Icon(Icons.arrow_forward_ios, size: 16), + onTap: _showRollingDatePicker, + dense: true, + ), + ], + ], + ), + ), + + const SizedBox(height: 24), + + // PDF Settings (Restored) + Text( + 'PDF設定', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + color: AppTheme.posimaiBlue, + ), + ), + const SizedBox(height: 8), + + // Paper Size Selection + Text( + '用紙サイズ', + style: Theme.of(context).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 8), + Row( + children: [ + _buildPaperSizeCard('a5', 'A5', '148 × 210 mm'), + const SizedBox(width: 12), + _buildPaperSizeCard('b5', 'B5', '182 × 257 mm'), + const SizedBox(width: 12), + _buildPaperSizeCard('a4', 'A4', '210 × 297 mm'), + ], + ), + const SizedBox(height: 16), + + // Orientation Toggle (Custom Layout) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('縦向き', style: TextStyle(fontWeight: FontWeight.bold)), + Switch( + value: ref.watch(pdfIsPortraitProvider), + activeColor: AppTheme.posimaiBlue, + onChanged: (val) => ref.read(pdfIsPortraitProvider.notifier).set(val), + ), + ], + ), + ), + const Divider(height: 1), + + // Density Slider (Pro Feature) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text('銘柄の間隔', style: TextStyle(fontWeight: FontWeight.bold)), + Text( + '${(ref.watch(pdfDensityProvider) * 100).round()}%', + style: TextStyle(fontWeight: FontWeight.bold, color: AppTheme.posimaiBlue), + ), + ], + ), + const SizedBox(height: 4), + Text( + '数値を上げると1枚に多く入ります', + style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Colors.grey), + ), + Slider( + value: ref.watch(pdfDensityProvider).clamp(1.0, 2.0), + min: 1.0, + max: 2.0, + divisions: 10, + label: '${(ref.watch(pdfDensityProvider) * 100).round()}%', + activeColor: AppTheme.posimaiBlue, + onChanged: (val) { + ref.read(pdfDensityProvider.notifier).set(val); + }, + ), + ], + ), + ), + const Divider(height: 1), + + // Color/Monochrome Toggle (Custom Layout) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text('カラー', style: TextStyle(fontWeight: FontWeight.bold)), + Switch( + value: !isMonochrome, // ON = Color (!Monochrome) + activeColor: AppTheme.posimaiBlue, + onChanged: (val) => setState(() { + isMonochrome = !val; // Toggle logic + ref.read(pdfIsMonochromeProvider.notifier).set(isMonochrome); + _saveSettings(); + }), + ), + ], + ), + ), + ], + ), + ), + SafeArea( + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Theme.of(context).cardColor, + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 8, + offset: const Offset(0, -2), + ), + ], + ), + child: Row( + children: [ + // 戻るボタン (左端) + SizedBox( + width: 56, + height: 56, + child: OutlinedButton( + onPressed: () => Navigator.pop(context), + style: OutlinedButton.styleFrom( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + side: BorderSide(color: Colors.grey[300]!), + padding: EdgeInsets.zero, + ), + child: Icon(Icons.arrow_back, color: Colors.grey[700]), + ), + ), + const SizedBox(width: 12), + + // プレビューボタン (右側いっぱいに広がる) + Expanded( + child: SizedBox( + height: 56, + child: ElevatedButton.icon( + onPressed: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => PdfPreviewScreen( + items: selectedItems, + title: _titleController.text, + date: _dateController.text, + includePhoto: includePhoto, + includePoem: includePoem, + includeChart: includeChart, + includePrice: includePrice, + includeDate: includeDate, + includeQr: includeQr, // New Argument + pdfSize: pdfSize, + isMonochrome: isMonochrome, + ), + ), + ); + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.posimaiBlue, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + icon: const Icon(Icons.picture_as_pdf), + label: const Text('プレビュー', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), + ), + ), + ), + ], + ), + ), + ), + ], + ), + ); + } + + Future _showExitDialog(BuildContext context, WidgetRef ref) async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('お品書き作成を終了しますか?'), + content: const Text('入力内容は保存されません。'), + actions: [ + TextButton( + child: const Text('キャンセル'), + onPressed: () => Navigator.pop(context, false), + ), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.posimaiBlue, + foregroundColor: Colors.white, + ), + onPressed: () => Navigator.pop(context, true), + child: const Text('終了'), + ), + ], + ), + ); + + if (confirmed == true && context.mounted) { + ref.read(menuModeProvider.notifier).set(false); + ref.read(selectedMenuSakeIdsProvider.notifier).clear(); + Navigator.of(context).popUntil((route) => route.isFirst); + } + } + + Widget _buildPaperSizeCard(String value, String label, String sublabel) { + final isSelected = pdfSize == value; + final colorScheme = Theme.of(context).colorScheme; + + return Expanded( + child: GestureDetector( + onTap: () { + setState(() { + pdfSize = value; + _saveSettings(); + }); + }, + child: Container( + padding: const EdgeInsets.symmetric(vertical: 16), + decoration: BoxDecoration( + color: isSelected + ? colorScheme.primaryContainer.withOpacity(0.2) + : colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: isSelected ? colorScheme.primary : Colors.transparent, + width: 2, + ), + ), + child: Column( + children: [ + Icon( + isSelected ? Icons.check_box : Icons.crop_portrait, // Use crop_portrait as generic paper icon + color: isSelected ? colorScheme.primary : colorScheme.onSurfaceVariant, + ), + const SizedBox(height: 8), + Text( + label, + style: TextStyle( + fontWeight: FontWeight.bold, + color: isSelected ? colorScheme.primary : colorScheme.onSurface, + ), + ), + Text( + sublabel, + style: TextStyle( + fontSize: 10, + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/screens/pdf_preview_screen.dart b/lib/screens/pdf_preview_screen.dart new file mode 100644 index 0000000..01fe89c --- /dev/null +++ b/lib/screens/pdf_preview_screen.dart @@ -0,0 +1,248 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:printing/printing.dart'; +import 'package:pdf/pdf.dart'; +import '../services/pdf_service.dart'; +import '../models/sake_item.dart'; +import '../providers/menu_providers.dart'; +import '../theme/app_theme.dart'; + +class PdfPreviewScreen extends ConsumerWidget { + final List items; + final String title; + final String date; + final bool includePhoto; + final bool includePoem; + final bool includeChart; + final bool includePrice; + final bool includeDate; + final bool includeQr; + final String pdfSize; + final bool isMonochrome; + + const PdfPreviewScreen({ + super.key, + required this.items, + required this.title, + required this.date, + required this.includePhoto, + required this.includePoem, + required this.includeChart, + required this.includePrice, + required this.includeDate, + required this.includeQr, + required this.pdfSize, + required this.isMonochrome, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + // Phase 4: Watch new PDF Settings + final isPortrait = ref.watch(pdfIsPortraitProvider); + final density = ref.watch(pdfDensityProvider); + + return Scaffold( + backgroundColor: Colors.white, // 明示的に白背景を設定 + appBar: AppBar( + title: const Text('お品書きプレビュー'), + automaticallyImplyLeading: false, // ヘッダー戻るボタンを無効化 + actions: [ + IconButton( + icon: const Icon(Icons.close), + onPressed: () => _showExitDialog(context, ref), + tooltip: '終了', + ), + ], + ), + body: PdfPreview( + build: (format) => PdfService.generateMenuPdf( + items, + title: title, + date: date, + includePhoto: includePhoto, + includePoem: includePoem, + includeChart: includeChart, + includePrice: includePrice, + includeDate: includeDate, + includeQr: includeQr, + pdfSize: pdfSize, + isMonochrome: isMonochrome, + isPortrait: isPortrait, + density: density, + ), + initialPageFormat: _getPageFormat(pdfSize, isPortrait), + canChangePageFormat: false, // User selects in Edit screen + canChangeOrientation: false, + canDebug: false, + allowSharing: false, // Handled by custom button + allowPrinting: false, // Handled by custom button if needed (or share -> print) + useActions: false, // Disable default bottom bar + scrollViewDecoration: const BoxDecoration( + color: Colors.white, + ), + pdfPreviewPageDecoration: BoxDecoration( + color: Colors.white, // FIX: Ensure paper is white + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.3), + blurRadius: 10, + spreadRadius: 2, + ), + ], + ), + loadingWidget: const Center(child: CircularProgressIndicator()), + onError: (context, error) => Center(child: Text('エラーが発生しました: $error')), + ), + bottomNavigationBar: SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // 操作ガイド (フッターの真上) + Container( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + child: Center( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: Colors.black.withOpacity(0.7), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.touch_app, color: Colors.white, size: 16), + const SizedBox(width: 8), + Flexible( + child: Text( + 'メニュー領域を2回タップで拡大・縮小', + style: TextStyle(color: Colors.white, fontSize: 12), + textAlign: TextAlign.center, + ), + ), + ], + ), + ), + ), + ), + // フッターボタン + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Theme.of(context).cardColor, + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 8, + offset: const Offset(0, -2), + ), + ], + ), + child: Row( + children: [ + // 戻るボタン (左端) + SizedBox( + width: 56, + height: 56, + child: OutlinedButton( + onPressed: () => Navigator.pop(context), + style: OutlinedButton.styleFrom( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + side: BorderSide(color: Colors.grey[300]!), + padding: EdgeInsets.zero, + ), + child: Icon(Icons.arrow_back, color: Colors.grey[700]), + ), + ), + const SizedBox(width: 12), + + // 共有・ダウンロードボタン (右側いっぱいに広がる) + Expanded( + child: SizedBox( + height: 56, + child: ElevatedButton.icon( + onPressed: () async { + // PDF生成して共有 + final bytes = await PdfService.generateMenuPdf( + items, + title: title, + date: date, + includePhoto: includePhoto, + includePoem: includePoem, + includeChart: includeChart, + includePrice: includePrice, + includeDate: includeDate, + includeQr: includeQr, + pdfSize: pdfSize, + isMonochrome: isMonochrome, + isPortrait: isPortrait, + density: density, + ); + await Printing.sharePdf(bytes: bytes, filename: 'shinagaki.pdf'); + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.posimaiBlue, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + icon: const Icon(Icons.share), + label: const Text('共有・保存', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), + ), + ), + ), + ], + ), + ), + ], + ), + ), + ); + } + + PdfPageFormat _getPageFormat(String size, bool isPortrait) { + PdfPageFormat format; + switch (size) { + case 'a5': + format = PdfPageFormat.a5; break; + case 'b5': + format = PdfPageFormat(257 * PdfPageFormat.mm, 182 * PdfPageFormat.mm); break; + case 'a4': + default: + format = PdfPageFormat.a4; break; + } + return isPortrait ? format : format.landscape; + } + + Future _showExitDialog(BuildContext context, WidgetRef ref) async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('お品書き作成を終了しますか?'), + content: const Text('入力内容は保存されません。'), + actions: [ + TextButton( + child: const Text('キャンセル'), + onPressed: () => Navigator.pop(context, false), + ), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.posimaiBlue, + foregroundColor: Colors.white, + ), + onPressed: () => Navigator.pop(context, true), + child: const Text('終了'), + ), + ], + ), + ); + + if (confirmed == true && context.mounted) { + ref.read(menuModeProvider.notifier).set(false); + ref.read(selectedMenuSakeIdsProvider.notifier).clear(); + Navigator.of(context).popUntil((route) => route.isFirst); + } + } +} diff --git a/lib/screens/placeholders/brewery_map_screen.dart b/lib/screens/placeholders/brewery_map_screen.dart new file mode 100644 index 0000000..20d72c5 --- /dev/null +++ b/lib/screens/placeholders/brewery_map_screen.dart @@ -0,0 +1,378 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:lucide_icons/lucide_icons.dart'; +import '../../providers/sake_list_provider.dart'; +import '../../widgets/map/pixel_japan_map.dart'; +import '../../theme/app_theme.dart'; +import '../../models/maps/japan_map_data.dart'; + +class BreweryMapScreen extends ConsumerStatefulWidget { + const BreweryMapScreen({super.key}); + + @override + ConsumerState createState() => _BreweryMapScreenState(); +} + +class _BreweryMapScreenState extends ConsumerState { + final TransformationController _transformationController = TransformationController(); + bool _isMapInitialized = false; + + @override + void dispose() { + _transformationController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final sakeListAsync = ref.watch(sakeListProvider); + + return sakeListAsync.when( + data: (sakeList) { + // Extract visited prefectures + final visitedPrefectures = sakeList + .map((s) => s.displayData.prefecture) + .where((p) => p != null && p.isNotEmpty) + .where((p) => p != '不明' && p != '海外') + .map((p) => p!) + .toSet(); + + final totalPrefs = 47; + final visitedCount = visitedPrefectures.length; + final progress = visitedCount / totalPrefs; + + return Scaffold( + appBar: AppBar( + title: const Text('酒蔵マップ'), + centerTitle: true, + ), + body: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 12), + + // 1. Stats Card (Compact) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24.0), + child: _buildStatsCard(context, progress, visitedCount), + ), + + const SizedBox(height: 8), + + // 2. Legend + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.start, // Left align + children: [ + _buildLegendDot(Colors.grey[300]!, '未開拓'), + const SizedBox(width: 12), + _buildLegendDot(AppTheme.posimaiBlue, '制覇済'), + ], + ), + ), + + const SizedBox(height: 4), + + // 3. Map (Maximized & Interactive) + // Use LayoutBuilder to determine available width and set initial scale + SizedBox( + height: 420, // Increased to 420 to prevent Okinawa from being cut off + child: LayoutBuilder( + builder: (context, constraints) { + // Map logical width is approx 26 cols * 32.0 = 832.0 + const double mapWidth = 26 * 32.0; + + // Calculate scale to fit width (95% to allow slight margin) + final availableWidth = constraints.maxWidth; + final fitScale = (availableWidth / mapWidth) * 0.95; + + if (!_isMapInitialized) { + // Center horizontally + final xOffset = (availableWidth - (mapWidth * fitScale)) / 2; + + final matrix = Matrix4.identity() + ..translate(xOffset, 10.0) + ..scale(fitScale); + + _transformationController.value = matrix; + _isMapInitialized = true; + } + + return Stack( + children: [ + InteractiveViewer( + transformationController: _transformationController, + // Large boundary margin to allow panning even at min scale + boundaryMargin: const EdgeInsets.all(500), + // Allow zooming out strictly to the fit size (with 5% margin for gesture safety) + minScale: fitScale * 0.95, + maxScale: fitScale * 6.0, + constrained: false, + child: PixelJapanMap( + visitedPrefectures: visitedPrefectures, + onPrefectureTap: (pref) { + _showPrefectureStats(context, pref, sakeList); + }, + ), + ), + Positioned( + bottom: 16, + right: 16, + child: FloatingActionButton.small( + heroTag: 'map_reset', + backgroundColor: Colors.white.withOpacity(0.9), + foregroundColor: AppTheme.posimaiBlue, + elevation: 2, + onPressed: () { + // Reset to initial state (Fit Width) + final xOffset = (availableWidth - (mapWidth * fitScale)) / 2; + final matrix = Matrix4.identity() + ..translate(xOffset, 10.0) + ..scale(fitScale); + _transformationController.value = matrix; + }, + child: const Icon(LucideIcons.rotateCcw, size: 20), + ), + ), + ], + ); + } + ), + ), + + const SizedBox(height: 12), + + // 4. Regional Status + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('地域別制覇状況', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)), + const SizedBox(height: 12), + _buildRegionalStatusGrid(context, visitedPrefectures, sakeList), + ], + ), + ), + + const SizedBox(height: 40), + ], + ), + ), + ); + }, + loading: () => const Center(child: CircularProgressIndicator()), + error: (err, stack) => Center(child: Text('エラーが発生しました: $err')), + ); + } + + // Helper to show prefecture stats toast/snackbar + void _showPrefectureStats(BuildContext context, String pref, List sakeList) { + final count = sakeList.where((s) => s.displayData.prefecture?.contains(pref) ?? false).length; + ScaffoldMessenger.of(context).hideCurrentSnackBar(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('$pref: $count本 記録済み'), + duration: const Duration(seconds: 2), + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + ); + } + + Widget _buildStatsCard(BuildContext context, double progress, int visitedCount) { + return Container( + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16), // Compact padding + decoration: BoxDecoration( + color: Theme.of(context).cardColor, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + Column( + children: [ + Text('制覇率', style: Theme.of(context).textTheme.bodySmall), + const SizedBox(height: 4), + Text( + '${(progress * 100).toStringAsFixed(1)}%', + style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold, color: AppTheme.posimaiBlue), + ), + ], + ), + Container(width: 1, height: 30, color: Colors.grey[200]), + Column( + children: [ + Text('制覇数', style: Theme.of(context).textTheme.bodySmall), + const SizedBox(height: 4), + RichText( + text: TextSpan( + children: [ + TextSpan( + text: '$visitedCount', + style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold, color: Theme.of(context).textTheme.bodyLarge?.color), + ), + TextSpan(text: ' / 47', style: TextStyle(fontSize: 14, color: Colors.grey[500])), + ], + ), + ), + ], + ), + ], + ), + ); + } + + Widget _buildRegionalStatusGrid(BuildContext context, Set visitedPrefectures, List sakeList) { + // Group logic could be moved to JapanMapData helper if complex, but simple loop works here + final regions = JapanMapData.regionNames.keys.toList(); + + return GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, // Reverting to 2 columns as requested to avoid overflow + childAspectRatio: 2.5, // Wider aspect ratio for 2 columns + crossAxisSpacing: 12, + mainAxisSpacing: 12, + ), + itemCount: regions.length, + itemBuilder: (context, index) { + final regionId = regions[index]; + final regionName = JapanMapData.regionNames[regionId] ?? ''; + + final prefsInRegion = JapanMapData.prefectureNames.entries + .where((e) => JapanMapData.getRegionId(e.key) == regionId) + .map((e) { + // Fix: Don't strip '道' from '北海道'. Only strip suffixes from others. + if (e.value == '北海道') return '北海道'; + return e.value.replaceAll(RegExp(r'(都|府|県)$'), ''); + }) + .toList(); + + final totalInRegion = prefsInRegion.length; + final visitedInRegion = prefsInRegion.where((p) => visitedPrefectures.any((vp) => vp.startsWith(p))).length; + final isComplete = visitedInRegion == totalInRegion && totalInRegion > 0; + + return _buildRegionCard(context, regionName, visitedInRegion, totalInRegion, isComplete, () { + _showRegionDetailDialog(context, regionName, prefsInRegion, sakeList); + }); + }, + ); + } + + void _showRegionDetailDialog(BuildContext context, String regionName, List prefs, List sakeList) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (context) { + return DraggableScrollableSheet( + initialChildSize: 0.5, + minChildSize: 0.3, + maxChildSize: 0.8, + expand: false, + builder: (context, scrollController) { + return Column( + children: [ + const SizedBox(height: 12), + Container(width: 40, height: 4, decoration: BoxDecoration(color: Colors.grey[300], borderRadius: BorderRadius.circular(2))), + const SizedBox(height: 16), + Text('$regionNameの制覇状況', style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), + const SizedBox(height: 16), + Expanded( + child: ListView.separated( + controller: scrollController, + itemCount: prefs.length, + separatorBuilder: (_, __) => const Divider(height: 1), + itemBuilder: (context, index) { + final pref = prefs[index]; + // Find count + final count = sakeList.where((s) => s.displayData.prefecture?.contains(pref) ?? false).length; + final isConquered = count > 0; + + return ListTile( + leading: Icon( + isConquered ? LucideIcons.checkCircle2 : LucideIcons.circle, + color: isConquered ? AppTheme.posimaiBlue : Colors.grey[300], + ), + title: Text(pref), + trailing: Text( + '$count本', + style: TextStyle( + fontWeight: isConquered ? FontWeight.bold : FontWeight.normal, + color: isConquered ? AppTheme.posimaiBlue : Colors.grey + ) + ), + ); + }, + ), + ), + ], + ); + }, + ); + }, + ); + } + + Widget _buildRegionCard(BuildContext context, String name, int current, int total, bool isComplete, VoidCallback onTap) { + final color = isComplete ? AppTheme.posimaiBlue : Theme.of(context).cardColor; + final textColor = isComplete ? Colors.white : Theme.of(context).textTheme.bodyLarge?.color; + final subTextColor = isComplete ? Colors.white.withOpacity(0.8) : Colors.grey[600]; + + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), // Tighter padding + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(12), + border: isComplete ? null : Border.all(color: Colors.grey[200]!), // Lighter border + boxShadow: [ + if(!isComplete) BoxShadow(color: Colors.black.withOpacity(0.03), blurRadius: 4, offset: const Offset(0,2)) + ] + ), + child: Column( // changed to column for 3-grid layout + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text(name, style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13, color: textColor)), + const SizedBox(height: 2), + Row( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ + Text('$current', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: textColor)), + Text('/$total', style: TextStyle(fontSize: 11, color: subTextColor)), + ], + ) + ], + ), + ), + ); + } + + Widget _buildLegendDot(Color color, String label) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container(width: 10, height: 10, decoration: BoxDecoration(color: color, borderRadius: BorderRadius.circular(2))), + const SizedBox(width: 4), + Text(label, style: const TextStyle(fontSize: 11, color: Colors.grey)), + ], + ); + } +} diff --git a/lib/screens/placeholders/placeholders.dart b/lib/screens/placeholders/placeholders.dart new file mode 100644 index 0000000..5de3186 --- /dev/null +++ b/lib/screens/placeholders/placeholders.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../providers/theme_provider.dart'; + +export 'brewery_map_screen.dart'; +export 'sommelier_screen.dart'; +export '../scan_screen.dart'; + +// ScanARScreen moved to ../scan_screen.dart and exported above + + + + + + +class InstaSupportScreen extends StatelessWidget { + const InstaSupportScreen({super.key}); + @override + Widget build(BuildContext context) { + return const Scaffold( + body: Center(child: Text('インスタ販促支援 (Coming Soon)')), + ); + } +} + + + +class AnalyticsScreen extends StatelessWidget { + const AnalyticsScreen({super.key}); + @override + Widget build(BuildContext context) { + return const Scaffold( + body: Center(child: Text('売上・在庫分析 (Coming Soon)')), + ); + } +} diff --git a/lib/screens/placeholders/sommelier_screen.dart b/lib/screens/placeholders/sommelier_screen.dart new file mode 100644 index 0000000..1c7159b --- /dev/null +++ b/lib/screens/placeholders/sommelier_screen.dart @@ -0,0 +1,253 @@ +import 'dart:io'; +import 'dart:typed_data'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:lucide_icons/lucide_icons.dart'; +import 'package:screenshot/screenshot.dart'; +import 'package:share_plus/share_plus.dart'; +import 'package:path_provider/path_provider.dart'; +import '../../providers/sake_list_provider.dart'; +import '../../services/shuko_diagnosis_service.dart'; +import '../../theme/app_theme.dart'; +import '../../widgets/sake_radar_chart.dart'; + +class SommelierScreen extends ConsumerStatefulWidget { + const SommelierScreen({super.key}); + + @override + ConsumerState createState() => _SommelierScreenState(); +} + +class _SommelierScreenState extends ConsumerState { + final ScreenshotController _screenshotController = ScreenshotController(); + bool _isSharing = false; + + Future _shareCard() async { + setState(() { + _isSharing = true; + }); + + try { + final image = await _screenshotController.capture( + delay: const Duration(milliseconds: 10), + pixelRatio: 3.0, // High res for sharing + ); + + if (image == null) return; + + final directory = await getTemporaryDirectory(); + final imagePath = await File('${directory.path}/sommelier_card.png').create(); + await imagePath.writeAsBytes(image); + + // Share the file + await Share.shareXFiles( + [XFile(imagePath.path)], + text: '私の酒向タイプはこれ! #ポンシュルーム', + ); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('シェアに失敗しました: $e')), + ); + } + } finally { + if (mounted) { + setState(() { + _isSharing = false; + }); + } + } + } + + @override + Widget build(BuildContext context) { + final sakeListAsync = ref.watch(sakeListProvider); + final diagnosisService = ref.watch(shukoDiagnosisServiceProvider); + + return Scaffold( + appBar: AppBar( + title: const Text('AIソムリエ診断'), + centerTitle: true, + ), + body: sakeListAsync.when( + data: (sakeList) { + final profile = diagnosisService.diagnose(sakeList); + + return SingleChildScrollView( + padding: const EdgeInsets.all(24.0), + child: Column( + children: [ + Screenshot( + controller: _screenshotController, + child: _buildShukoCard(context, profile), + ), + const SizedBox(height: 32), + _buildActionButtons(context), + ], + ), + ); + }, + loading: () => const Center(child: CircularProgressIndicator()), + error: (err, stack) => Center(child: Text('エラー: $err')), + ), + ); + } + + Widget _buildShukoCard(BuildContext context, ShukoProfile profile) { + final isDark = Theme.of(context).brightness == Brightness.dark; + + return Container( + width: double.infinity, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(24), + // Premium Card Gradient + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: isDark + ? [const Color(0xFF2C3E50), const Color(0xFF000000)] + : [const Color(0xFFE0EAFC), const Color(0xFFCFDEF3)], + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.1), + blurRadius: 20, + offset: const Offset(0, 10), + ), + ], + ), + child: Stack( + children: [ + // Background Pattern (Optional subtle decoration) + Positioned( + right: -20, + top: -20, + child: Icon( + LucideIcons.sparkles, + size: 150, + color: isDark ? Colors.white.withOpacity(0.05) : Colors.blue.withOpacity(0.05), + ), + ), + + // Subtle Sake Emoji + Positioned( + left: 20, + top: 20, + child: Opacity( + opacity: 0.3, // Subtle + child: const Text( + '🍶', + style: TextStyle(fontSize: 40), + ), + ), + ), + + Padding( + padding: const EdgeInsets.all(32.0), + child: Column( + children: [ + // 1. Header (Name & Rank) + Text( + 'あなたの酒向タイプ', + style: Theme.of(context).textTheme.bodySmall?.copyWith(letterSpacing: 1.5), + ), + const SizedBox(height: 16), + + // 2. Title + Text( + profile.title, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 28, + fontWeight: FontWeight.bold, + color: AppTheme.posimaiBlue, + shadows: [ + Shadow( + color: AppTheme.posimaiBlue.withOpacity(0.3), + blurRadius: 10, + offset: const Offset(0, 2), + ), + ], + ), + ), + const SizedBox(height: 16), + + Text( + profile.description, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + height: 1.5, + ), + ), + const SizedBox(height: 32), + + SizedBox( + height: 200, + child: SakeRadarChart( + tasteStats: { + 'aroma': (profile.avgStats.aroma).round(), + 'bitterness': (profile.avgStats.richness).round(), + 'sweetness': (profile.avgStats.sweetness).round(), + 'acidity': (profile.avgStats.alcoholFeeling).round(), + 'body': (profile.avgStats.fruitiness).round(), + }, + primaryColor: AppTheme.posimaiBlue, + ), + ), + const SizedBox(height: 24), + + // 4. Stats Footer + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface.withOpacity(0.5), + borderRadius: BorderRadius.circular(12), + ), + child: Text( + '分析対象: ${profile.analyzedCount} / ${profile.totalSakeCount} 本', + style: Theme.of(context).textTheme.bodySmall, + ), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildActionButtons(BuildContext context) { + return Column( + children: [ + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: _isSharing ? null : _shareCard, + icon: _isSharing + ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2)) + : const Icon(LucideIcons.share2), + label: const Padding( + padding: EdgeInsets.symmetric(vertical: 12.0), + child: Text('カードをシェアする'), + ), + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.posimaiBlue, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + ), + ), + const SizedBox(height: 16), + TextButton( + onPressed: () { + // Chat entry point (Plan B) + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('AIソムリエとの会話は次のステップです')), + ); + }, + child: const Text('AIソムリエに詳しく聞く'), + ), + ], + ); + } +} diff --git a/lib/screens/sake_detail_screen.dart b/lib/screens/sake_detail_screen.dart new file mode 100644 index 0000000..27f7158 --- /dev/null +++ b/lib/screens/sake_detail_screen.dart @@ -0,0 +1,1523 @@ +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:lucide_icons/lucide_icons.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:path/path.dart' as path; +import '../models/sake_item.dart'; +import '../services/gemini_service.dart'; +import '../services/sake_recommendation_service.dart'; +import '../widgets/analyzing_dialog.dart'; +import '../widgets/sake_3d_carousel.dart'; +import 'package:hive_flutter/hive_flutter.dart'; +import '../providers/sake_list_provider.dart'; +import '../widgets/sake_radar_chart.dart'; +import '../services/pricing_calculator.dart'; +import '../providers/theme_provider.dart'; +import '../models/user_profile.dart'; +import 'camera_screen.dart'; + + +class SakeDetailScreen extends ConsumerStatefulWidget { + final SakeItem sake; + + const SakeDetailScreen({super.key, required this.sake}); + + @override + ConsumerState createState() => _SakeDetailScreenState(); +} + +class _SakeDetailScreenState extends ConsumerState { + // To trigger rebuilds if we don't switch to a stream + late SakeItem _sake; + int _currentImageIndex = 0; + + @override + void initState() { + super.initState(); + _sake = widget.sake; + } + + @override + Widget build(BuildContext context) { + // Determine confidence text color + final score = _sake.metadata.aiConfidence ?? 0; + final Color confidenceColor = score > 80 ? Colors.green + : score > 50 ? Colors.orange + : Colors.red; + + // スマートレコメンド (Phase 1-8 Enhanced) + final allSakeAsync = ref.watch(rawSakeListItemsProvider); + final allSake = allSakeAsync.asData?.value ?? []; + + // 新しいレコメンドエンジン使用(五味チャート類似度込み) + final recommendations = SakeRecommendationService.getRecommendations( + target: _sake, + allItems: allSake, + limit: 10, + ); + + final relatedItems = recommendations.map((rec) => rec.item).toList(); + + return Scaffold( + body: CustomScrollView( + slivers: [ + SliverAppBar( + expandedHeight: 400.0, + floating: false, + pinned: true, + iconTheme: const IconThemeData(color: Colors.white), + actions: [ + IconButton( + icon: Icon(_sake.userData.isFavorite ? Icons.favorite : Icons.favorite_border), + color: _sake.userData.isFavorite ? Colors.pink : Colors.white, + tooltip: 'お気に入り', + onPressed: () => _toggleFavorite(), + ), + IconButton( + icon: const Icon(LucideIcons.refreshCw), + color: Colors.white, + tooltip: 'AI再解析', + onPressed: () => _reanalyze(context), + ), + IconButton( + icon: const Icon(LucideIcons.trash2), + color: Colors.white, + tooltip: '削除', + onPressed: () => _showDeleteDialog(context), + ), + ], + flexibleSpace: FlexibleSpaceBar( + background: Stack( + fit: StackFit.expand, + children: [ + _sake.displayData.imagePaths.length > 1 + ? Stack( + fit: StackFit.expand, + children: [ + PageView.builder( + itemCount: _sake.displayData.imagePaths.length, + onPageChanged: (index) => setState(() => _currentImageIndex = index), + itemBuilder: (context, index) { + return Image.file( + File(_sake.displayData.imagePaths[index]), + fit: BoxFit.cover, + ); + }, + ), + // Simple Indicator + Positioned( + bottom: 16, + right: 16, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: Colors.black54, + borderRadius: BorderRadius.circular(16), + ), + child: Text( + '${_currentImageIndex + 1} / ${_sake.displayData.imagePaths.length}', + style: const TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.bold), + ), + ), + ), + // Photo Edit Button + Positioned( + bottom: 16, + left: 16, + child: FloatingActionButton.small( + heroTag: 'photo_edit', + backgroundColor: Colors.white, + onPressed: () => _showPhotoEditModal(context), + child: const Icon(LucideIcons.image, color: Colors.black87), + ), + ), + ], + ) + : Stack( + fit: StackFit.expand, + children: [ + Hero( + tag: _sake.id, + child: _sake.displayData.imagePaths.isNotEmpty + ? Image.file( + File(_sake.displayData.imagePaths.first), + fit: BoxFit.cover, + ) + : Container( + color: Colors.grey[300], + child: const Icon(LucideIcons.image, size: 80, color: Colors.grey), + ), + ), + // Photo Edit Button for single image + Positioned( + bottom: 16, + left: 16, + child: FloatingActionButton.small( + heroTag: 'photo_edit_single', + backgroundColor: Colors.white, + onPressed: () => _showPhotoEditModal(context), + child: const Icon(LucideIcons.image, color: Colors.black87), + ), + ), + ], + ), + // Scrim for Header Icons Visibility + Positioned( + top: 0, + left: 0, + right: 0, + height: 100, + child: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.black.withValues(alpha: 0.7), + Colors.transparent, + ], + ), + ), + ), + ), + ], + ), + ), + ), + SliverToBoxAdapter( + child: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Theme.of(context).scaffoldBackgroundColor, + Theme.of(context).primaryColor.withValues(alpha: 0.05), + ], + ), + ), + padding: const EdgeInsets.all(24.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Confidence Badge + if (_sake.metadata.aiConfidence != null && _sake.itemType != ItemType.set) + Center( + child: Container( + margin: const EdgeInsets.only(bottom: 16), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + decoration: BoxDecoration( + color: confidenceColor.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(20), + border: Border.all(color: confidenceColor.withValues(alpha: 0.5)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(LucideIcons.sparkles, size: 14, color: confidenceColor), + const SizedBox(width: 6), + Text( + 'AI確信度: $score%', + style: TextStyle( + color: confidenceColor, + fontWeight: FontWeight.bold, + fontSize: 12, + ), + ), + ], + ), + ), + ), + + // Brand Name + Center( + child: InkWell( + onTap: () => _showTextEditDialog( + context, + title: '銘柄名を編集', + initialValue: _sake.displayData.name, + onSave: (value) async { + final box = Hive.box('sake_items'); + final updated = _sake.copyWith(name: value, isUserEdited: true); + await box.put(_sake.key, updated); + setState(() => _sake = updated); + }, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: Text( + _sake.displayData.name, + style: Theme.of(context).textTheme.headlineMedium?.copyWith( + fontWeight: FontWeight.bold, + letterSpacing: 1.2, + ), + textAlign: TextAlign.center, + ), + ), + const SizedBox(width: 8), + Icon(LucideIcons.edit3, size: 20, color: Colors.grey[600]), + ], + ), + ), + ), + const SizedBox(height: 8), + + // Brand / Prefecture + if (_sake.itemType != ItemType.set) + Center( + child: InkWell( + onTap: () => _showBreweryEditDialog(context), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: Text( + '${_sake.displayData.brewery} / ${_sake.displayData.prefecture}', + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: Theme.of(context).brightness == Brightness.dark + ? Colors.grey[400] + : Colors.grey[600], + fontWeight: FontWeight.w500, + ), + ), + ), + const SizedBox(width: 8), + Icon(LucideIcons.edit3, size: 18, color: Colors.grey[500]), + ], + ), + ), + ), + const SizedBox(height: 16), + + // Tags Row + if (_sake.hiddenSpecs.flavorTags.isNotEmpty) + Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: () => _showTagEditDialog(context), + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Wrap( + alignment: WrapAlignment.center, + spacing: 8, + children: _sake.hiddenSpecs.flavorTags.map((tag) => Chip( + label: Text(tag, style: const TextStyle(fontSize: 10)), + visualDensity: VisualDensity.compact, + backgroundColor: Theme.of(context).primaryColor.withValues(alpha: 0.1), + )).toList(), + ), + ), + ), + ), + ), + + const SizedBox(height: 24), + + // AI Catchcopy (Mincho) + if (_sake.displayData.catchCopy != null && _sake.itemType != ItemType.set) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Text( + _sake.displayData.catchCopy!, + style: GoogleFonts.zenOldMincho( + fontSize: 24, + height: 1.5, + fontWeight: FontWeight.w500, + color: Theme.of(context).brightness == Brightness.dark + ? Colors.white + : Theme.of(context).primaryColor, // Adaptive + ), + textAlign: TextAlign.center, + ), + ), + + const SizedBox(height: 32), + const SizedBox(height: 24), + const Divider(), + const SizedBox(height: 24), + + // Taste Radar Chart (Phase 1-8) + if (_sake.hiddenSpecs.tasteStats.isNotEmpty && _sake.itemType != ItemType.set) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Column( + children: [ + Row( + children: [ + Icon(LucideIcons.barChart2, size: 16, color: Theme.of(context).colorScheme.onSurface), // Adaptive Color + const SizedBox(width: 8), + Text( + 'Visual Tasting', + style: Theme.of(context).textTheme.labelLarge?.copyWith( + color: Theme.of(context).colorScheme.onSurface, // Adaptive Color + fontWeight: FontWeight.bold, + ), + ), + ], + ), + const SizedBox(height: 16), + SizedBox( + height: 200, + child: SakeRadarChart( + tasteStats: _sake.hiddenSpecs.tasteStats, + primaryColor: Theme.of(context).primaryColor, + ), + ), + ], + ), + ), + + const SizedBox(height: 24), + const Divider(), + const SizedBox(height: 24), + + // Description + if (_sake.hiddenSpecs.description != null && _sake.itemType != ItemType.set) + Text( + _sake.hiddenSpecs.description!, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + height: 1.8, + fontSize: 16, + ), + ), + + const SizedBox(height: 32), + const Divider(), + const SizedBox(height: 16), + + // AI Specs Accordion + if (_sake.itemType != ItemType.set) + ExpansionTile( + leading: Icon(LucideIcons.sparkles, color: Theme.of(context).primaryColor), + title: const Text('AIで分析された情報', style: TextStyle(fontWeight: FontWeight.bold)), + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Column( + children: [ + _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 SizedBox(height: 16), + const Divider(), + const SizedBox(height: 16), + + // Memo Field + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(LucideIcons.fileText, size: 16, color: Theme.of(context).primaryColor), + const SizedBox(width: 8), + Text( + 'メモ', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ], + ), + const SizedBox(height: 12), + TextField( + controller: TextEditingController(text: _sake.userData.memo ?? ''), + maxLines: 4, + decoration: InputDecoration( + hintText: 'お店独自の情報をメモ', + border: const OutlineInputBorder(), + filled: true, + fillColor: Theme.of(context).cardColor, + ), + onChanged: (value) async { + // Auto-save + final box = Hive.box('sake_items'); + final updated = _sake.copyWith(memo: value, isUserEdited: true); + await box.put(_sake.key, updated); + setState(() => _sake = updated); + }, + ), + ], + ), + + const SizedBox(height: 48), + + // Related Items 3D Carousel (Phase 1-8 Enhanced) + if (_sake.itemType != ItemType.set) ...[ // Hide for Set Items + Row( + children: [ + Icon(LucideIcons.sparkles, size: 16, color: Theme.of(context).colorScheme.onSurface), + const SizedBox(width: 8), + Text( + 'あわせて飲みたい', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.onSurface, // Adaptive + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + '五味チャート・タグ・酒蔵・産地から自動選出', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).brightness == Brightness.dark + ? Colors.grey[400] + : Colors.grey[600], + ), + ), + const SizedBox(height: 16), + relatedItems.isNotEmpty + ? Sake3DCarousel( + items: relatedItems, + height: 220, + ) + : Container( + height: 120, + alignment: Alignment.center, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(LucideIcons.info, color: Colors.grey[400], size: 32), + const SizedBox(height: 8), + Text( + '関連する日本酒を追加すると\nおすすめが表示されます', + textAlign: TextAlign.center, + style: TextStyle(color: Colors.grey[600], fontSize: 12), + ), + ], + ), + ), + const SizedBox(height: 48), + ], + + // Diagnostic Placeholder (Phase 1-6) + Container( + width: double.infinity, + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + border: Border.all( + color: Theme.of(context).primaryColor.withValues(alpha: 0.3), + style: BorderStyle.solid, + width: 2, + ), + borderRadius: BorderRadius.circular(16), + color: Theme.of(context).cardColor.withValues(alpha: 0.5), + ), + child: Column( + children: [ + Icon(LucideIcons.wand2, color: Theme.of(context).colorScheme.onSurface, size: 32), + const SizedBox(height: 12), + Text( + '診断スタンプ (Coming Soon)', + style: Theme.of(context).textTheme.labelLarge?.copyWith( + color: Theme.of(context).colorScheme.onSurface, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 4), + Text( + 'MBTI診断との相性がここに表示されます', + style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Colors.grey), + ), + ], + ), + ), + const SizedBox(height: 24), + ], + ), + ), + ), + + // Phase 2-3: Business Pricing Section + SliverToBoxAdapter( + child: Consumer( + builder: (context, ref, _) { + final userProfile = ref.watch(userProfileProvider); + if (!userProfile.isBusinessMode) return const SizedBox.shrink(); + + return _buildPricingSection(context, userProfile); + }, + ), + ), + + // End of Pricing Section + + // Gap with Safe Area + SliverPadding( + padding: EdgeInsets.only(bottom: MediaQuery.paddingOf(context).bottom), + ), + ], + ), + ); + } + + bool _isAnalyzing = false; + DateTime? _quotaLockoutTime; + + + Future _toggleFavorite() async { + final box = Hive.box('sake_items'); + + final newItem = _sake.copyWith(isFavorite: !_sake.userData.isFavorite); + + await box.put(_sake.key, newItem); + setState(() { + _sake = newItem; + }); + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(newItem.userData.isFavorite ? 'お気に入りに追加しました' : 'お気に入りを解除しました'), + duration: const Duration(milliseconds: 1000), + ), + ); + } + + Future _reanalyze(BuildContext context) async { + // 1. Check Locks + if (_isAnalyzing) return; + + // 2. Check Quota Lockout + if (_quotaLockoutTime != null) { + final remaining = _quotaLockoutTime!.difference(DateTime.now()); + if (remaining.isNegative) { + setState(() => _quotaLockoutTime = null); // Reset if time passed + } else { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('AI利用制限中です。あと${remaining.inSeconds}秒お待ちください。')), + ); + return; + } + } + + if (_sake.displayData.imagePaths.isEmpty) return; + + setState(() => _isAnalyzing = true); + + try { + showDialog( + context: context, + barrierDismissible: false, + builder: (context) => const AnalyzingDialog(), + ); + + final geminiService = GeminiService(); + final result = await geminiService.analyzeSakeLabel(_sake.displayData.imagePaths); + + final newItem = _sake.copyWith( + name: result.name ?? _sake.displayData.name, + brand: result.brand ?? _sake.displayData.brewery, + prefecture: result.prefecture ?? _sake.displayData.prefecture, + description: result.description ?? _sake.hiddenSpecs.description, + catchCopy: result.catchCopy ?? _sake.displayData.catchCopy, + confidenceScore: result.confidenceScore, + flavorTags: result.flavorTags.isNotEmpty ? result.flavorTags : _sake.hiddenSpecs.flavorTags, + tasteStats: result.tasteStats.isNotEmpty ? result.tasteStats : _sake.hiddenSpecs.tasteStats, + ); + + final box = Hive.box('sake_items'); + await box.put(_sake.key, newItem); + + setState(() { + _sake = newItem; + }); + + if (context.mounted) { + Navigator.pop(context); // Close dialog + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('再解析が完了しました')), + ); + } + + } catch (e) { + if (context.mounted) { + Navigator.pop(context); // Close dialog (safely pops the top route, hopefully the dialog) + + // Check for Quota Error to set Lockout + if (e.toString().contains('Quota') || e.toString().contains('429')) { + setState(() { + _quotaLockoutTime = DateTime.now().add(const Duration(minutes: 1)); + }); + } + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('エラー: $e')), + ); + } + } finally { + if (mounted) { + setState(() => _isAnalyzing = false); + } + } + } + + + void _showTagEditDialog(BuildContext context) { + final TextEditingController tagController = TextEditingController(); + final allTags = _sake.hiddenSpecs.flavorTags.toSet(); + + showDialog( + context: context, + builder: (ctx) => StatefulBuilder( + builder: (context, setModalState) { + return AlertDialog( + title: const Text('タグ編集'), + contentPadding: const EdgeInsets.fromLTRB(24, 20, 24, 0), + content: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: MediaQuery.of(context).size.width * 0.85, + minWidth: 300, + ), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Wrap( + spacing: 8, + runSpacing: 8, + children: allTags.map((tag) => Chip( + label: Text(tag), + onDeleted: () { + setModalState(() => allTags.remove(tag)); + }, + )).toList(), + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: TextField( + controller: tagController, + decoration: const InputDecoration( + hintText: '新しいタグを追加', + isDense: true, + ), + onSubmitted: (val) { + if (val.trim().isNotEmpty) { + setModalState(() { + allTags.add(val.trim()); + tagController.clear(); + }); + } + }, + ), + ), + IconButton( + icon: const Icon(LucideIcons.plus), + onPressed: () { + if (tagController.text.trim().isNotEmpty) { + setModalState(() { + allTags.add(tagController.text.trim()); + tagController.clear(); + }); + } + }, + ), + ], + ), + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('キャンセル'), + ), + TextButton( + onPressed: () { + _updateTags(allTags.toList()); + Navigator.pop(context); + }, + child: const Text('保存'), + ), + ], + ); + } + ), + ); + } + + Future _updateTags(List newTags) async { + final box = Hive.box('sake_items'); + final newItem = _sake.copyWith( + flavorTags: newTags, + isUserEdited: true, + ); + + await box.put(_sake.key, newItem); + setState(() => _sake = newItem); + } + + // Phase 2-3: Business Pricing UI (Simplified) + Widget _buildPricingSection(BuildContext context, UserProfile userProfile) { + // Calculated Price + final calculatedPrice = PricingCalculator.calculatePrice(_sake); + + return Container( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.orange.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.orange.withValues(alpha: 0.3)), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(LucideIcons.coins, color: Colors.orange[800], size: 18), + const SizedBox(width: 6), + Text( + '価格設定', + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14, color: Colors.orange[900]), + ), + ], + ), + const SizedBox(height: 4), + Text( + calculatedPrice > 0 + ? '現在${calculatedPrice}円' + : '未設定', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: calculatedPrice > 0 ? Colors.orange[900] : Colors.grey, + ), + ), + ], + ), + ElevatedButton( + onPressed: () => _showPriceSettingsDialog(userProfile), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.orange, + foregroundColor: Colors.white, + ), + child: const Text('編集'), + ), + ], + ), + ); + } + + Future _showPriceSettingsDialog(UserProfile argProfile) async { + // final userProfile = ref.read(userProfileProvider); // Now passed as arg + if (!argProfile.isBusinessMode) return; + + int? cost = _sake.userData.costPrice; + int? manual = _sake.userData.price; + double markup = _sake.userData.markup; + // Copy existing variants + Map variants = Map.from(_sake.userData.priceVariants ?? {}); + + // Tiny State for Inline Adding + String tempName = ''; + String tempPrice = ''; // String to handle empty better + final TextEditingController nameController = TextEditingController(); + final TextEditingController priceController = TextEditingController(); + + await showDialog( + context: context, + builder: (context) => StatefulBuilder( + builder: (context, setModalState) { + final price = PricingCalculator.calculatePrice( + _sake.copyWith(costPrice: cost, manualPrice: manual, markup: markup) + ); + + void addVariant() { + if (tempName.isNotEmpty && tempPrice.isNotEmpty) { + final parsedPrice = int.tryParse(tempPrice); + if (parsedPrice != null) { + setModalState(() { + variants[tempName] = parsedPrice; + // Clear inputs + tempName = ''; + tempPrice = ''; + nameController.clear(); + priceController.clear(); + }); + } + } + } + + return AlertDialog( + title: const Text('価格設定', style: TextStyle(fontWeight: FontWeight.bold)), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // 1. Manual Price (Top Priority) + TextFormField( + initialValue: manual?.toString() ?? '', + keyboardType: TextInputType.number, + decoration: const InputDecoration( + labelText: '販売価格 (税込)', + hintText: '手動で設定する場合に入力', + suffixText: '円', + border: OutlineInputBorder(), + ), + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 18), + onChanged: (v) => setModalState(() => manual = int.tryParse(v)), + ), + const SizedBox(height: 24), + + // 2. Variants (Inline Entry) + const Text('提供バリエーション', style: TextStyle(fontWeight: FontWeight.bold)), + const SizedBox(height: 8), + + // Presets Chips + Wrap( + spacing: 8.0, + runSpacing: 4.0, + children: [ + for (var preset in ['グラス (90ml)', '一合 (180ml)', 'ボトル (720ml)']) + ChoiceChip( + label: Text(preset), + selected: tempName == preset, + onSelected: (selected) { + setModalState(() { + // Auto-fill logic + if (selected) { + tempName = preset; + nameController.text = preset; + } + }); + }, + backgroundColor: Colors.grey[200], + selectedColor: Colors.orange[100], + ), + ], + ), + const SizedBox(height: 12), + + // Inline Inputs + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + flex: 3, + child: TextField( + controller: nameController, + decoration: const InputDecoration( + labelText: '名称', + hintText: '例: 徳利', + isDense: true, + border: OutlineInputBorder(), + ), + onChanged: (v) => tempName = v, + ), + ), + const SizedBox(width: 8), + Expanded( + flex: 2, + child: TextField( + controller: priceController, // Using controller to clear + keyboardType: TextInputType.number, + decoration: const InputDecoration( + labelText: '価格', + suffixText: '円', + isDense: true, + border: OutlineInputBorder(), + ), + onChanged: (v) => tempPrice = v, + ), + ), + ], + ), + const SizedBox(height: 8), + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + icon: const Icon(LucideIcons.plus), + label: const Text('リストに追加'), + style: ElevatedButton.styleFrom( + backgroundColor: (tempName.isNotEmpty && tempPrice.isNotEmpty) ? Colors.orange : Colors.grey, + foregroundColor: Colors.white, + ), + onPressed: (tempName.isNotEmpty && tempPrice.isNotEmpty) + ? addVariant + : null, + ), + ), + + const SizedBox(height: 16), + + // List of Added Variants + if (variants.isNotEmpty) + Container( + decoration: BoxDecoration( + border: Border.all(color: Colors.grey.withValues(alpha: 0.3)), + borderRadius: BorderRadius.circular(8), + ), + child: Column( + children: [ + ...variants.entries.map((e) => Column( + children: [ + ListTile( + dense: true, + title: Text(e.key, style: const TextStyle(fontWeight: FontWeight.w500)), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text('${PricingCalculator.formatPrice(e.value)}円', style: const TextStyle(fontWeight: FontWeight.bold)), + const SizedBox(width: 8), + IconButton( + icon: const Icon(LucideIcons.x, color: Colors.grey, size: 18), + onPressed: () { + setModalState(() { + variants.remove(e.key); + }); + }, + ), + ], + ), + ), + if (e.key != variants.keys.last) const Divider(height: 1), + ], + )).toList(), + ], + ), + ), + + const SizedBox(height: 24), + + // 3. Auto Calculation (Accordion) + ExpansionTile( + title: const Text('原価・掛率設定 (自動計算)', style: TextStyle(fontSize: 14, color: Colors.grey)), + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 0, vertical: 8), + child: Column( + children: [ + TextFormField( + initialValue: cost?.toString() ?? '', + keyboardType: TextInputType.number, + decoration: const InputDecoration( + labelText: '仕入れ値 (円)', + suffixText: '円', + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.currency_yen), + ), + onChanged: (v) => setModalState(() => cost = int.tryParse(v)), + ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('掛率: ${markup.toStringAsFixed(1)}倍'), + TextButton( + child: const Text('リセット'), + onPressed: () => setModalState(() => markup = argProfile.defaultMarkup), + ) + ], + ), + Slider( + value: markup, + min: 1.0, + max: 5.0, + divisions: 40, + label: markup.toStringAsFixed(1), + activeColor: Colors.orange, + onChanged: (v) => setModalState(() => markup = v), + ), + Text( + '参考計算価格: ${PricingCalculator.formatPrice(PricingCalculator.roundUpTo50((cost ?? 0) * markup))}円 (50円切上)', + style: const TextStyle(color: Colors.grey, fontSize: 12), + ), + ], + ), + ), + ], + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('キャンセル'), + ), + ElevatedButton( + onPressed: () { + _updatePricing( + costPrice: cost, + manualPrice: manual, + markup: markup, + priceVariants: variants.isEmpty ? null : variants, + ); + Navigator.pop(context); + }, + child: const Text('保存'), + ), + ], + ); + } + ), + ); + } + + Future _updatePricing({int? costPrice, int? manualPrice, double? markup, Map? priceVariants}) async { + final box = Hive.box('sake_items'); + final newItem = _sake.copyWith( + costPrice: costPrice, + manualPrice: manualPrice, + markup: markup ?? _sake.userData.markup, + priceVariants: priceVariants, + isUserEdited: true, + ); + + await box.put(_sake.key, newItem); + setState(() => _sake = newItem); + } + + Future _showDeleteDialog(BuildContext context) async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Row( + children: [ + Icon(LucideIcons.alertTriangle, color: Colors.orange, size: 24), + const SizedBox(width: 8), + const Text('削除確認'), + ], + ), + content: Text('「${_sake.displayData.name}」を削除しますか?\nこの操作は取り消せません。'), + actions: [ + TextButton( + child: const Text('キャンセル'), + onPressed: () => Navigator.pop(context, false), + ), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red, // Keeps Red for delete as it is destructive + foregroundColor: Colors.white, + ), + child: const Text('削除'), + onPressed: () => Navigator.pop(context, true), + ), + ], + ), + ); + + if (confirmed == true && mounted) { + final box = Hive.box('sake_items'); + await box.delete(_sake.key); + + if (mounted) { + Navigator.pop(context); // Return to previous screen + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('削除しました')), + ); + } + } + } + + /// スペック行を構築 + Widget _buildSpecRow(String label, String value) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label, style: const TextStyle(fontWeight: FontWeight.w500)), + Text(value, style: TextStyle(color: Colors.grey[700])), + ], + ), + ); + } + + /// テキスト編集ダイアログを表示 + Future _showTextEditDialog( + BuildContext context, { + required String title, + required String initialValue, + required Future Function(String) onSave, + }) async { + final controller = TextEditingController(text: initialValue); + await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(title), + content: TextField( + controller: controller, + decoration: const InputDecoration( + border: OutlineInputBorder(), + ), + autofocus: true, + maxLines: null, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('キャンセル'), + ), + ElevatedButton( + onPressed: () async { + await onSave(controller.text); + if (context.mounted) Navigator.pop(context); + }, + child: const Text('保存'), + ), + ], + ), + ); + } + + /// 酒蔵・都道府県編集ダイアログを表示 + Future _showBreweryEditDialog(BuildContext context) async { + final breweryController = TextEditingController(text: _sake.displayData.brewery); + final prefectureController = TextEditingController(text: _sake.displayData.prefecture); + await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('酒蔵・都道府県を編集'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: breweryController, + decoration: const InputDecoration( + labelText: '酒蔵', + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 16), + TextField( + controller: prefectureController, + decoration: const InputDecoration( + labelText: '都道府県', + border: OutlineInputBorder(), + ), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('キャンセル'), + ), + ElevatedButton( + onPressed: () async { + final box = Hive.box('sake_items'); + final updated = _sake.copyWith( + brand: breweryController.text, + prefecture: prefectureController.text, + isUserEdited: true, + ); + await box.put(_sake.key, updated); + setState(() => _sake = updated); + if (context.mounted) Navigator.pop(context); + }, + child: const Text('保存'), + ), + ], + ), + ); + } + + /// 写真編集モーダルを表示 + Future _showPhotoEditModal(BuildContext context) async { + await showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (context) => _PhotoEditModal( + sake: _sake, + onUpdated: (updatedSake) { + setState(() => _sake = updatedSake); + }, + ), + ); + } +} + +/// 写真編集モーダルウィジェット +class _PhotoEditModal extends StatefulWidget { + final SakeItem sake; + final Function(SakeItem) onUpdated; + + const _PhotoEditModal({ + required this.sake, + required this.onUpdated, + }); + + @override + State<_PhotoEditModal> createState() => _PhotoEditModalState(); +} + +class _PhotoEditModalState extends State<_PhotoEditModal> { + late List _imagePaths; + + @override + void initState() { + super.initState(); + _imagePaths = List.from(widget.sake.displayData.imagePaths); + } + + @override + Widget build(BuildContext context) { + return Container( + height: MediaQuery.of(context).size.height * 0.7, + decoration: BoxDecoration( + color: Theme.of(context).scaffoldBackgroundColor, + borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), + ), + child: Column( + children: [ + // Handle bar + Container( + margin: const EdgeInsets.only(top: 12, bottom: 8), + width: 40, + height: 4, + decoration: BoxDecoration( + color: Colors.grey[300], + borderRadius: BorderRadius.circular(2), + ), + ), + // Header + Padding( + padding: const EdgeInsets.all(16), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + '写真を編集', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + IconButton( + icon: const Icon(LucideIcons.x), + onPressed: () => Navigator.pop(context), + ), + ], + ), + ), + const Divider(height: 1), + // Photo grid + Expanded( + child: _imagePaths.isEmpty + ? Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(LucideIcons.image, size: 64, color: Colors.grey[400]), + const SizedBox(height: 16), + Text( + '写真を追加してください', + style: TextStyle(color: Colors.grey[600]), + ), + ], + ), + ) + : ReorderableListView.builder( + padding: const EdgeInsets.all(16), + itemCount: _imagePaths.length, + onReorder: (oldIndex, newIndex) { + setState(() { + if (oldIndex < newIndex) { + newIndex -= 1; + } + final item = _imagePaths.removeAt(oldIndex); + _imagePaths.insert(newIndex, item); + }); + }, + itemBuilder: (context, index) { + final path = _imagePaths[index]; + return Card( + key: ValueKey(path), + margin: const EdgeInsets.only(bottom: 12), + child: ListTile( + leading: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.file( + File(path), + width: 60, + height: 60, + fit: BoxFit.cover, + ), + ), + title: Text( + index == 0 ? 'メイン写真' : '写真 ${index + 1}', + style: const TextStyle(fontWeight: FontWeight.w500), + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(LucideIcons.gripVertical, color: Colors.grey[400]), + const SizedBox(width: 8), + IconButton( + icon: const Icon(LucideIcons.trash2, color: Colors.red), + onPressed: () => _deletePhoto(index), + ), + ], + ), + ), + ); + }, + ), + ), + // Bottom buttons + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Theme.of(context).cardColor, + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 8, + offset: const Offset(0, -2), + ), + ], + ), + child: SafeArea( + child: Row( + children: [ + Expanded( + child: OutlinedButton.icon( + icon: const Icon(LucideIcons.camera), + label: const Text('写真を追加'), + onPressed: _addPhoto, + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 14), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: ElevatedButton( + onPressed: _saveChanges, + style: ElevatedButton.styleFrom( + backgroundColor: Theme.of(context).primaryColor, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 14), + ), + child: const Text('保存'), + ), + ), + ], + ), + ), + ), + ], + ), + ); + } + + Future _addPhoto() async { + final picker = ImagePicker(); + + // Show bottom sheet with camera/gallery options + final source = await showModalBottomSheet( + context: context, + builder: (context) => SafeArea( + child: Wrap( + children: [ + ListTile( + leading: const Icon(LucideIcons.camera), + title: const Text('カメラで撮影'), + onTap: () async { + Navigator.pop(context); // Close sheet + // Navigate to CameraScreen in returnPath mode + final result = await Navigator.push( + context, + MaterialPageRoute(builder: (_) => CameraScreen(mode: CameraMode.returnPath)), + ); + if (result is String) { + // Add the path + await _saveNewPhoto(result); + } + }, + ), + ListTile( + leading: const Icon(LucideIcons.image), + title: const Text('ギャラリーから選択'), + onTap: () => Navigator.pop(context, ImageSource.gallery), + ), + ], + ), + ), + ); + + if (source == null) return; + + // Handle Gallery (Camera is handled in ListTile callback) + if (source == ImageSource.gallery) { + try { + final XFile? pickedFile = await picker.pickImage(source: source); + if (pickedFile == null) return; + + // Save to app directory + final appDir = await getApplicationDocumentsDirectory(); + final fileName = '${DateTime.now().millisecondsSinceEpoch}.jpg'; + final savedPath = path.join(appDir.path, fileName); + await File(pickedFile.path).copy(savedPath); + + await _saveNewPhoto(savedPath); + + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('エラー: $e')), + ); + } + } + } + } + + Future _saveNewPhoto(String imagePath) async { + setState(() { + _imagePaths.add(imagePath); + }); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('写真を追加しました')), + ); + } + } + + void _deletePhoto(int index) { + setState(() { + _imagePaths.removeAt(index); + }); + } + + Future _saveChanges() async { + final box = Hive.box('sake_items'); + final updatedSake = widget.sake.copyWith( + imagePaths: _imagePaths, + isUserEdited: true, + ); + await box.put(widget.sake.key, updatedSake); + widget.onUpdated(updatedSake); + if (mounted) { + Navigator.pop(context); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('写真を更新しました')), + ); + } + } +} diff --git a/lib/screens/scan_screen.dart b/lib/screens/scan_screen.dart new file mode 100644 index 0000000..8f3176f --- /dev/null +++ b/lib/screens/scan_screen.dart @@ -0,0 +1,292 @@ +import 'dart:convert'; +import 'package:flutter/material.dart'; +import 'package:mobile_scanner/mobile_scanner.dart'; +import '../theme/app_theme.dart'; +import '../models/schema/sake_taste_stats.dart'; +import '../widgets/sake_radar_chart.dart'; + +class ScanARScreen extends StatefulWidget { + const ScanARScreen({super.key}); + + @override + State createState() => _ScanARScreenState(); +} + +class _ScanARScreenState extends State with SingleTickerProviderStateMixin { + final MobileScannerController _controller = MobileScannerController(); + bool _isScanning = true; + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _onDetect(BarcodeCapture capture) { + if (!_isScanning) return; + + final List barcodes = capture.barcodes; + for (final barcode in barcodes) { + if (barcode.rawValue != null) { + try { + final Map data = jsonDecode(barcode.rawValue!); + // Check for key fields to validate it's our QR + if (data.containsKey('id') && data.containsKey('n')) { + _isScanning = false; // Stop scanning logic + // Show Card + _showDigitalCard(data); + break; + } + } catch (e) { + // Not JSON or not our format + } + } + } + } + + void _showDigitalCard(Map data) async { + // Play sound? (Optional) + + await showDialog( + context: context, + barrierDismissible: true, + builder: (context) => _DigitalSakeCardDialog(data: data), + ); + + // Resume scanning after dialog closes + if (mounted) { + setState(() { + _isScanning = true; + }); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Stack( + children: [ + MobileScanner( + controller: _controller, + onDetect: _onDetect, + ), + // Overlay Design + Container( + decoration: BoxDecoration( + border: Border.symmetric( + horizontal: BorderSide(color: Colors.black.withOpacity(0.5), width: 100), + vertical: BorderSide(color: Colors.black.withOpacity(0.5), width: 40), + ), + ), + ), + Center( + child: Container( + width: 280, + height: 280, + decoration: BoxDecoration( + border: Border.all(color: AppTheme.posimaiBlue.withOpacity(0.8), width: 2), + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: AppTheme.posimaiBlue.withOpacity(0.4), + blurRadius: 20, + spreadRadius: 2, + ), + ], + ), + child: Stack( + children: [ + // Corners + Positioned(top: 0, left: 0, child: _Corner(isTop: true, isLeft: true)), + Positioned(top: 0, right: 0, child: _Corner(isTop: true, isLeft: false)), + Positioned(bottom: 0, left: 0, child: _Corner(isTop: false, isLeft: true)), + Positioned(bottom: 0, right: 0, child: _Corner(isTop: false, isLeft: false)), + // Center Animation (Scanner line) - simplified + Center( + child: Container( + height: 2, + color: Colors.white.withOpacity(0.5), + ), + ) + ], + ), + ), + ), + const Positioned( + top: 60, + left: 0, + right: 0, + child: Text( + '銘柄カードのQRをスキャン', + textAlign: TextAlign.center, + style: TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.bold, + shadows: [Shadow(color: Colors.black, blurRadius: 4)], + ), + ), + ), + ], + ), + ); + } +} + +class _Corner extends StatelessWidget { + final bool isTop; + final bool isLeft; + + const _Corner({required this.isTop, required this.isLeft}); + + @override + Widget build(BuildContext context) { + const double size = 32; // Slightly larger for rounded look + const double thickness = 4; + const double radius = 16; + + return Container( + width: size, + height: size, + decoration: BoxDecoration( + color: Colors.transparent, + borderRadius: BorderRadius.only( + topLeft: isTop && isLeft ? const Radius.circular(radius) : Radius.zero, + topRight: isTop && !isLeft ? const Radius.circular(radius) : Radius.zero, + bottomLeft: !isTop && isLeft ? const Radius.circular(radius) : Radius.zero, + bottomRight: !isTop && !isLeft ? const Radius.circular(radius) : Radius.zero, + ), + border: Border( + top: isTop ? const BorderSide(color: Colors.white, width: thickness) : BorderSide.none, + bottom: !isTop ? const BorderSide(color: Colors.white, width: thickness) : BorderSide.none, + left: isLeft ? const BorderSide(color: Colors.white, width: thickness) : BorderSide.none, + right: !isLeft ? const BorderSide(color: Colors.white, width: thickness) : BorderSide.none, + ), + ), + ); + } +} + +class _DigitalSakeCardDialog extends StatelessWidget { + final Map data; + + const _DigitalSakeCardDialog({required this.data}); + + @override + Widget build(BuildContext context) { + // Unpack data + final name = data['n'] ?? '不明'; + final brewery = data['b'] ?? '不明'; + final prefecture = data['p']?.toString() ?? '不明'; + + // Stats for chart + final aroma = (data['s'] is num) ? -1 : 3; // 's' is sweetness in my QR? + // Wait, toQrJson: s=sweetness, y=body, a=alcohol. + // Radar Chart needs: aroma, bitterness, sweetness, acidity, body. + // We only have S, Y, A. (Sweetness, Body, Alcohol). + // We'll map them visually or just show what we have. + // A proper implementation plan would fetch the full data if available, but offline we only use QR data. + // Let's map: + // Sweetness -> Sweetness + // Body -> Body + // Alcohol -> Bitterness? (High alcohol often behaves like dry/bitter) + // Aroma -> Default 3 + // Acidity -> Default 3 + // Or just show stars. + // The user praised the Radar Chart, so let's try to populate it even partially. + + final sweetness = (data['s'] as num?)?.toInt() ?? 3; + final body = (data['y'] as num?)?.toInt() ?? 3; + final alcohol = (data['a'] as num?)?.toInt() ?? 3; + + final radarData = { + 'aroma': 3, + 'bitterness': alcohol, // Proxy + 'sweetness': sweetness, + 'acidity': 3, + 'body': body, + }; + + return Dialog( + backgroundColor: Colors.transparent, + elevation: 0, + child: TweenAnimationBuilder( + duration: const Duration(milliseconds: 500), + curve: Curves.elasticOut, + tween: Tween(begin: 0.5, end: 1.0), + builder: (context, scale, child) { + return Transform.scale(scale: scale, child: child); + }, + child: Container( + width: 320, + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + gradient: const LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Color(0xFFE3F2FD), Color(0xFFF3E5F5)], // Light Blue to Light Purple + ), + borderRadius: BorderRadius.circular(20), + boxShadow: [ + BoxShadow(color: Colors.black.withOpacity(0.2), blurRadius: 20, spreadRadius: 5), + ], + border: Border.all(color: Colors.white, width: 2), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Badge + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: AppTheme.posimaiBlue, + borderRadius: BorderRadius.circular(20), + ), + child: const Text('New Discovery!', style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 12)), + ), + const SizedBox(height: 16), + + // Name + Text( + name, + style: const TextStyle(fontSize: 22, fontWeight: FontWeight.bold, height: 1.2), + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + '$brewery / $prefecture', + style: const TextStyle(fontSize: 14, color: Colors.grey), + ), + + const SizedBox(height: 20), + + // Chart + SizedBox( + height: 180, + child: SakeRadarChart( + tasteStats: radarData, + primaryColor: AppTheme.posimaiBlue, + ), + ), + + const SizedBox(height: 24), + + // Actions + ElevatedButton.icon( + onPressed: () => Navigator.pop(context), + icon: const Icon(Icons.check), + label: const Text('閉じる'), + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.posimaiBlue, + foregroundColor: Colors.white, + minimumSize: const Size(double.infinity, 48), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/screens/shop_settings_screen.dart b/lib/screens/shop_settings_screen.dart new file mode 100644 index 0000000..71e7396 --- /dev/null +++ b/lib/screens/shop_settings_screen.dart @@ -0,0 +1,117 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:lucide_icons/lucide_icons.dart'; +import '../providers/theme_provider.dart'; +import '../widgets/settings/app_settings_section.dart'; +import '../widgets/settings/other_settings_section.dart'; +import '../widgets/settings/backup_settings_section.dart'; + +class ShopSettingsScreen extends ConsumerStatefulWidget { + const ShopSettingsScreen({super.key}); + + @override + ConsumerState createState() => _ShopSettingsScreenState(); +} + +class _ShopSettingsScreenState extends ConsumerState { + @override + void initState() { + super.initState(); + } + + @override + Widget build(BuildContext context) { + final userProfile = ref.watch(userProfileProvider); + final isDark = Theme.of(context).brightness == Brightness.dark; + + return Scaffold( + appBar: AppBar( + title: const Text('店舗設定'), + centerTitle: true, + ), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + // Business Config Section + _buildSectionHeader(context, 'ビジネス設定', LucideIcons.briefcase), + Card( + color: isDark ? const Color(0xFF1E1E1E) : null, + child: ListTile( + leading: Icon(LucideIcons.percent, color: isDark ? Colors.orange[300] : Theme.of(context).primaryColor), + title: const Text('基本掛率'), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text('×', style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + color: isDark ? Colors.grey[400] : Colors.grey[600], + )), + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + decoration: BoxDecoration( + color: isDark ? Colors.grey[800] : Colors.grey[100], + borderRadius: BorderRadius.circular(8), + border: Border.all(color: isDark ? Colors.grey[700]! : Colors.grey[300]!), + ), + child: DropdownButton( + value: userProfile.defaultMarkup, + isDense: true, + underline: const SizedBox(), + items: List.generate(41, (index) { + final val = 1.0 + (index * 0.1); + return DropdownMenuItem( + value: double.parse(val.toStringAsFixed(1)), + child: Text(val.toStringAsFixed(1)), + ); + }), + onChanged: (val) { + if (val != null) { + ref.read(userProfileProvider.notifier).setDefaultMarkup(val); + } + }, + ), + ), + ], + ), + ), + ), + const SizedBox(height: 24), + + // App Settings (Moved UP) + const AppearanceSettingsSection(), + const SizedBox(height: 24), + + // Other Settings (Renamed & Configured) + const OtherSettingsSection( + title: 'その他', + ), + + const SizedBox(height: 24), + const BackupSettingsSection(), + ], + ), + ); + } + + Widget _buildSectionHeader(BuildContext context, String title, IconData icon) { + final isDark = Theme.of(context).brightness == Brightness.dark; + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 4), + child: Row( + children: [ + Icon(icon, size: 20, color: isDark ? Colors.orange[300] : Theme.of(context).primaryColor), + const SizedBox(width: 8), + Text( + title, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + color: isDark ? Colors.grey[300] : Theme.of(context).primaryColor, + ), + ), + ], + ), + ); + } +} diff --git a/lib/screens/soul_screen.dart b/lib/screens/soul_screen.dart new file mode 100644 index 0000000..f83a741 --- /dev/null +++ b/lib/screens/soul_screen.dart @@ -0,0 +1,234 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:lucide_icons/lucide_icons.dart'; +import 'package:intl/intl.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import '../providers/theme_provider.dart'; +import '../widgets/settings/app_settings_section.dart'; +import '../widgets/settings/other_settings_section.dart'; +import '../widgets/settings/backup_settings_section.dart'; + +class SoulScreen extends ConsumerStatefulWidget { + const SoulScreen({super.key}); + + @override + ConsumerState createState() => _SoulScreenState(); +} + +class _SoulScreenState extends ConsumerState { + @override + void initState() { + super.initState(); + } + + @override + Widget build(BuildContext context) { + final userProfile = ref.watch(userProfileProvider); + final themeMode = userProfile.themeMode; + final fontPref = userProfile.fontPreference; + + return Scaffold( + appBar: AppBar( + title: const Text('マイページ'), + centerTitle: true, + ), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + // Identity Section + _buildSectionHeader('プロフィール (ID)', LucideIcons.fingerprint), + Card( + child: Column( + children: [ + ListTile( + leading: Icon(LucideIcons.brainCircuit, color: Theme.of(context).brightness == Brightness.dark ? Colors.grey[400] : null), + title: const Text('MBTI診断'), + subtitle: Text(userProfile.mbti ?? '未設定'), + trailing: Icon(LucideIcons.chevronRight, color: Theme.of(context).brightness == Brightness.dark ? Colors.grey[600] : null), + onTap: () => _showMbtiDialog(context, userProfile.mbti), + ), + const Divider(height: 1), + ListTile( + leading: Icon(LucideIcons.calendar, color: Theme.of(context).brightness == Brightness.dark ? Colors.grey[400] : null), + title: const Text('生年月日'), + subtitle: Text(userProfile.birthdate != null + ? DateFormat('yyyy/MM/dd').format(userProfile.birthdate!) + : '未設定 (四柱推命用)'), + trailing: Icon(LucideIcons.chevronRight, color: Theme.of(context).brightness == Brightness.dark ? Colors.grey[600] : null), + onTap: () => _pickBirthDate(context, userProfile.birthdate), + ), + ], + ), + ), + const SizedBox(height: 24), + + // App Settings + const AppearanceSettingsSection(), + + const SizedBox(height: 24), + + // other Settings + const OtherSettingsSection( + title: 'データ・その他', + ), + + const SizedBox(height: 24), + BackupSettingsSection(), + + // Roadmap + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primaryContainer.withValues(alpha: 0.1), // Lighter background + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.2), + ), + ), + child: Row( + children: [ + Icon(LucideIcons.heartHandshake, color: Theme.of(context).colorScheme.onSurface), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Future Update', + style: Theme.of(context).textTheme.labelSmall?.copyWith( + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.onSurface, + ), + ), + const SizedBox(height: 4), + const Text('カップル共有機能 (開発中)'), + ], + ), + ), + ], + ), + ), + const SizedBox(height: 40), + ], + ), + ); + } + + Widget _buildSectionHeader(String title, IconData icon) { + final isDark = Theme.of(context).brightness == Brightness.dark; + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 4), + child: Row( + children: [ + Icon(icon, size: 20, color: isDark ? Colors.orange[300] : Theme.of(context).primaryColor), + const SizedBox(width: 8), + Text( + title, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + color: isDark ? Colors.grey[300] : Theme.of(context).primaryColor, + ), + ), + ], + ), + ); + } + + void _showMbtiDialog(BuildContext context, String? current) { + const typesWithLabels = { + 'INTJ': '建築家', + 'INTP': '論理学者', + 'ENTJ': '指揮官', + 'ENTP': '討論者', + 'INFJ': '提唱者', + 'INFP': '仲介者', + 'ENFJ': '主人公', + 'ENFP': '広報運動家', + 'ISTJ': '管理者', + 'ISFJ': '擁護者', + 'ESTJ': '幹部', + 'ESFJ': '領事官', + 'ISTP': '巨匠', + 'ISFP': '冒険家', + 'ESTP': '起業家', + 'ESFP': 'エンターテイナー', + }; + + showDialog( + context: context, + builder: (context) => SimpleDialog( + title: const Text('MBTI タイプ選択'), + children: [ + SizedBox( + width: double.maxFinite, + height: 400, + child: ListView( + children: typesWithLabels.entries.map((entry) => SimpleDialogOption( + onPressed: () { + ref.read(userProfileProvider.notifier).setIdentity(mbti: entry.key); + Navigator.pop(context); + }, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: Row( + children: [ + Icon( + entry.key == current ? Icons.check : Icons.circle_outlined, + size: 20, + color: entry.key == current ? Theme.of(context).primaryColor : Colors.grey[400], + ), + const SizedBox(width: 16), + Expanded( + child: RichText( + text: TextSpan( + style: DefaultTextStyle.of(context).style.copyWith(fontSize: 16), + children: [ + TextSpan( + text: entry.key, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + TextSpan( + text: ' (${entry.value})', + style: TextStyle( + fontSize: 14, + color: Theme.of(context).brightness == Brightness.dark + ? Colors.grey[500] + : Colors.grey[600], + ), + ), + ], + ), + ), + ), + ], + ), + ), + )).toList(), + ), + ), + const Padding( + padding: EdgeInsets.all(16.0), + child: Text( + '※AIによる独自の相性診断です。遊び心としてお楽しみください', + style: TextStyle(fontSize: 10, color: Colors.grey), + textAlign: TextAlign.center, + ), + ), + ], + ), + ); + } + + Future _pickBirthDate(BuildContext context, DateTime? current) async { + final picked = await showDatePicker( + context: context, + initialDate: current ?? DateTime(2000), + firstDate: DateTime(1900), + lastDate: DateTime.now(), + locale: const Locale('ja'), // Ensure Japanese locale if initialized + ); + if (picked != null) { + ref.read(userProfileProvider.notifier).setIdentity(birthdate: picked); + } + } +} diff --git a/lib/services/backup_service.dart b/lib/services/backup_service.dart new file mode 100644 index 0000000..a56c38d --- /dev/null +++ b/lib/services/backup_service.dart @@ -0,0 +1,582 @@ +import 'dart:io'; +import 'dart:convert'; +import 'package:archive/archive_io.dart'; +import 'package:googleapis/drive/v3.dart' as drive; +import 'package:google_sign_in/google_sign_in.dart'; +import 'package:extension_google_sign_in_as_googleapis_auth/extension_google_sign_in_as_googleapis_auth.dart'; +import 'package:hive_flutter/hive_flutter.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:path/path.dart' as path; +import '../models/sake_item.dart'; + +/// Google Driveへのバックアップ・復元を管理するサービス +/// +/// 【主な機能】 +/// 1. Googleアカウント認証 +/// 2. Hiveデータ + 画像をZIPファイルにまとめる +/// 3. Google Driveへアップロード +/// 4. Google Driveからダウンロード +/// 5. ZIPファイルを展開してデータ復元 +class BackupService { + /// Google Sign Inインスタンス + /// スコープ: drive.file (アプリが作成したファイルのみアクセス可能) + final GoogleSignIn _googleSignIn = GoogleSignIn( + scopes: [drive.DriveApi.driveFileScope], + ); + + /// バックアップファイル名 + static const String backupFileName = 'ponshu_backup.zip'; + + /// 現在のGoogleアカウント情報を取得 + GoogleSignInAccount? get currentUser => _googleSignIn.currentUser; + + /// 初期化処理(サイレントサインイン試行) + Future init() async { + try { + await _googleSignIn.signInSilently(); + } catch (e) { + print('⚠️ サイレントサインインエラー: $e'); + } + } + + /// Googleアカウントでサインイン + /// + /// 【処理フロー】 + /// 1. Googleアカウント選択画面を表示 + /// 2. ユーザーが許可するとアカウント情報を取得 + /// 3. Google Drive APIへのアクセス権を取得 + /// + /// 【戻り値】 + /// - 成功: GoogleSignInAccount(アカウント情報) + /// - 失敗: null(キャンセルまたはエラー) + Future signIn() async { + try { + // 既にサインイン済みの場合は現在のアカウントを返す + if (_googleSignIn.currentUser != null) { + return _googleSignIn.currentUser; + } + + // サインイン画面を表示 + final account = await _googleSignIn.signIn(); + return account; + } catch (error) { + print('❌ Google Sign In エラー: $error'); + return null; + } + } + + /// サインアウト + Future signOut() async { + await _googleSignIn.signOut(); + } + + /// バックアップを作成してGoogle Driveにアップロード + /// + /// 【処理フロー】 + /// 1. ローカルのHiveデータをJSONに変換 + /// 2. 画像ファイルを収集 + /// 3. ZIPファイルに圧縮 + /// 4. Google Driveにアップロード + /// 5. 一時ファイルを削除 + /// + /// 【戻り値】 + /// - 成功: true + /// - 失敗: false + Future createBackup() async { + try { + // 1. サインイン確認 + final account = _googleSignIn.currentUser; + if (account == null) { + print('❌ サインインが必要です'); + return false; + } + + // 2. Drive APIクライアントを作成 + final authClient = await _googleSignIn.authenticatedClient(); + if (authClient == null) { + print('❌ 認証クライアントの取得に失敗しました'); + return false; + } + + final driveApi = drive.DriveApi(authClient); + + // 3. バックアップZIPファイルを作成 + final zipFile = await _createBackupZip(); + if (zipFile == null) { + print('❌ バックアップファイルの作成に失敗しました'); + return false; + } + + // 4. Google Driveにアップロード + final success = await _uploadToDrive(driveApi, zipFile); + + // 5. 一時ファイルを削除 + await zipFile.delete(); + + return success; + } catch (error) { + print('❌ バックアップ作成エラー: $error'); + return false; + } + } + + /// ローカルデータをZIPファイルにまとめる + /// + /// 【ファイル構造】 + /// ponshu_backup.zip + /// ├── sake_items.json (Hiveのデータ) + /// ├── settings.json (アプリ設定) + /// └── images/ + /// ├── uuid1.jpg + /// ├── uuid2.jpg + /// └── ... + Future _createBackupZip() async { + try { + final tempDir = await getTemporaryDirectory(); + final backupDir = Directory(path.join(tempDir.path, 'backup_temp')); + + // バックアップ用の一時ディレクトリを作成 + if (await backupDir.exists()) { + await backupDir.delete(recursive: true); + } + await backupDir.create(recursive: true); + + // 1. Hiveデータ取得 + final sakeBox = Hive.box('sake_items'); + final settingsBox = Hive.box('settings'); + + // データの整合性を保つためにフラッシュ + await sakeBox.flush(); + await settingsBox.flush(); + + // 2. sake_items.jsonを作成 + final sakeItems = sakeBox.values.map((item) => { + 'id': item.id, + 'displayData': { + 'name': item.displayData.name, + 'brewery': item.displayData.brewery, + 'prefecture': item.displayData.prefecture, + 'catchCopy': item.displayData.catchCopy, + 'imagePaths': item.displayData.imagePaths, + 'rating': item.displayData.rating, + }, + 'hiddenSpecs': { + 'description': item.hiddenSpecs.description, + 'tasteStats': item.hiddenSpecs.tasteStats, + 'flavorTags': item.hiddenSpecs.flavorTags, + 'sweetnessScore': item.hiddenSpecs.sweetnessScore, + 'bodyScore': item.hiddenSpecs.bodyScore, + }, + 'userData': { + 'isFavorite': item.userData.isFavorite, + 'isUserEdited': item.userData.isUserEdited, + 'price': item.userData.price, + 'costPrice': item.userData.costPrice, + 'markup': item.userData.markup, + 'priceVariants': item.userData.priceVariants, + }, + 'gamification': { + 'ponPoints': item.gamification.ponPoints, + }, + 'metadata': { + 'createdAt': item.metadata.createdAt.toIso8601String(), + 'aiConfidence': item.metadata.aiConfidence, + }, + 'itemType': item.itemType.toString().split('.').last, + }).toList(); + final sakeItemsFile = File(path.join(backupDir.path, 'sake_items.json')); + await sakeItemsFile.writeAsString(json.encode(sakeItems)); + print('📄 sake_items.json 作成: ${await sakeItemsFile.length()} bytes'); + + // 3. settings.jsonを作成 + final settings = Map.from(settingsBox.toMap()); + final settingsFile = File(path.join(backupDir.path, 'settings.json')); + await settingsFile.writeAsString(json.encode(settings)); + + // 4. 画像ファイルをコピー + final imagesDir = Directory(path.join(backupDir.path, 'images')); + await imagesDir.create(); + + final appDir = await getApplicationDocumentsDirectory(); + final imageFiles = appDir.listSync().where((file) => + file.path.endsWith('.jpg') || + file.path.endsWith('.jpeg') || + file.path.endsWith('.png') + ); + + for (var imageFile in imageFiles) { + final fileName = path.basename(imageFile.path); + await File(imageFile.path).copy(path.join(imagesDir.path, fileName)); + } + + // 5. ZIPファイルに圧縮 + final encoder = ZipFileEncoder(); + final zipPath = path.join(tempDir.path, backupFileName); + encoder.create(zipPath); + encoder.addDirectory(backupDir); + encoder.close(); + + // 6. 一時ディレクトリを削除 + await backupDir.delete(recursive: true); + + print('✅ バックアップZIPファイル作成完了: $zipPath'); + return File(zipPath); + } catch (error) { + print('❌ ZIP作成エラー: $error'); + return null; + } + } + + /// Google DriveにZIPファイルをアップロード + Future _uploadToDrive(drive.DriveApi driveApi, File zipFile) async { + try { + // 1. 既存のバックアップファイルを検索 + final fileList = await driveApi.files.list( + q: "name = '$backupFileName' and trashed = false", + spaces: 'drive', + ); + + // 2. 既存ファイルがあれば削除(完全上書き戦略) + if (fileList.files != null && fileList.files!.isNotEmpty) { + for (var file in fileList.files!) { + try { + await driveApi.files.delete(file.id!); + print('🗑️ 既存のバックアップファイルを削除しました: ${file.id}'); + } catch (e) { + print('⚠️ 既存ファイルの削除に失敗 (無視して続行): $e'); + } + } + } + + // 3. 新しいファイルをアップロード + final driveFile = drive.File(); + driveFile.name = backupFileName; + + final media = drive.Media(zipFile.openRead(), zipFile.lengthSync()); + + final uploadedFile = await driveApi.files.create( + driveFile, + uploadMedia: media, + ); + + if (uploadedFile.id == null) { + print('❌ アップロード後のID取得失敗'); + return false; + } + + print('✅ Google Driveにアップロードリクエスト完了 ID: ${uploadedFile.id}'); + + // 4. 検証ステップ:正しくアップロードされたか確認 + // APIの反映ラグを考慮して少し待機してから確認 + int retryCount = 0; + bool verified = false; + + while (retryCount < 3 && !verified) { + await Future.delayed(Duration(milliseconds: 1000 * (retryCount + 1))); + + try { + final check = await driveApi.files.get(uploadedFile.id!); + // getが成功すればファイルは存在する + if (check != null) { + verified = true; + print('✅ アップロード検証成功: ファイル存在確認済み'); + } + } catch (e) { + print('⚠️ 検証試行 ${retryCount + 1} 失敗: $e'); + } + retryCount++; + } + + return verified; + } catch (error) { + print('❌ アップロードエラー: $error'); + return false; + } + } + + /// Google Driveからバックアップを復元 + /// + /// 【処理フロー】 + /// 1. Google Driveからバックアップファイルをダウンロード + /// 2. ZIPファイルを展開 + /// 3. 現在のデータを退避(pre_restore_backup.zip) + /// 4. バックアップデータでHiveを上書き + /// 5. 画像ファイルを復元 + /// + /// 【注意】 + /// 現在のデータは完全に上書きされます。 + /// 事前に確認ダイアログを表示することを推奨します。 + Future restoreBackup() async { + try { + // 1. サインイン確認 + final account = _googleSignIn.currentUser; + if (account == null) { + print('❌ サインインが必要です'); + return false; + } + + // 2. Drive APIクライアントを作成 + final authClient = await _googleSignIn.authenticatedClient(); + if (authClient == null) { + print('❌ 認証クライアントの取得に失敗しました'); + return false; + } + + final driveApi = drive.DriveApi(authClient); + + // 3. 現在のデータを退避 + await _createPreRestoreBackup(); + + // 4. Google Driveからダウンロード + final zipFile = await _downloadFromDrive(driveApi); + if (zipFile == null) { + print('❌ ダウンロードに失敗しました'); + return false; + } + + // 5. データを復元 + final success = await _restoreFromZip(zipFile); + + // 6. 一時ファイルを削除 + await zipFile.delete(); + + return success; + } catch (error) { + print('❌ 復元エラー: $error'); + return false; + } + } + + /// 復元前に現在のデータを退避 + Future _createPreRestoreBackup() async { + try { + final tempDir = await getTemporaryDirectory(); + final backupPath = path.join(tempDir.path, 'pre_restore_backup.zip'); + + final zipFile = await _createBackupZip(); + if (zipFile != null) { + await zipFile.copy(backupPath); + await zipFile.delete(); + print('✅ 復元前のデータを退避しました: $backupPath'); + } + } catch (error) { + print('⚠️ データ退避エラー: $error'); + } + } + + /// Google DriveからZIPファイルをダウンロード + Future _downloadFromDrive(drive.DriveApi driveApi) async { + try { + // 1. バックアップファイルを検索 + final fileList = await driveApi.files.list( + q: "name = '$backupFileName' and trashed = false", + spaces: 'drive', + ); + + if (fileList.files == null || fileList.files!.isEmpty) { + print('❌ バックアップファイルが見つかりません'); + return null; + } + + final fileId = fileList.files!.first.id!; + + // 2. ファイルをダウンロード + final media = await driveApi.files.get( + fileId, + downloadOptions: drive.DownloadOptions.fullMedia, + ) as drive.Media; + + final tempDir = await getTemporaryDirectory(); + final downloadPath = path.join(tempDir.path, 'downloaded_backup.zip'); + final downloadFile = File(downloadPath); + + // 3. ストリームをファイルに書き込み + final sink = downloadFile.openWrite(); + await media.stream.pipe(sink); + + print('✅ ダウンロード完了: $downloadPath'); + return downloadFile; + } catch (error) { + print('❌ ダウンロードエラー: $error'); + return null; + } + } + + /// ZIPファイルからデータを復元 + Future _restoreFromZip(File zipFile) async { + try { + final tempDir = await getTemporaryDirectory(); + final extractDir = Directory(path.join(tempDir.path, 'restore_temp')); + + // 1. ZIP展開 + if (await extractDir.exists()) { + await extractDir.delete(recursive: true); + } + await extractDir.create(recursive: true); + + final bytes = await zipFile.readAsBytes(); + final archive = ZipDecoder().decodeBytes(bytes); + + for (var file in archive) { + final filename = file.name; + final data = file.content as List; + final extractPath = path.join(extractDir.path, filename); + + // __MACOSX などの不要なディレクトリはスキップ + if (filename.startsWith('__MACOSX')) continue; + + if (file.isFile) { + final outFile = File(extractPath); + await outFile.create(recursive: true); + await outFile.writeAsBytes(data); + print('📦 展開: $filename (${data.length} bytes)'); + } + } + + // デバッグ: 展開されたファイル一覧を表示 + print('📂 展開ディレクトリの中身:'); + extractDir.listSync(recursive: true).forEach((f) => print(' - ${path.basename(f.path)}')); + + // 2. sake_items.jsonを検索 (ルートまたはサブディレクトリ) + File? sakeItemsFile; + final potentialFiles = extractDir.listSync(recursive: true).whereType(); + + try { + sakeItemsFile = potentialFiles.firstWhere((f) => path.basename(f.path) == 'sake_items.json'); + } catch (e) { + // 見つからない場合 + print('❌ sake_items.json が見つかりません'); + } + + if (sakeItemsFile != null && await sakeItemsFile.exists()) { + final sakeItemsJson = json.decode(await sakeItemsFile.readAsString()) as List; + print('🔍 復元対象データ数: ${sakeItemsJson.length}件'); + final sakeBox = Hive.box('sake_items'); + + await sakeBox.clear(); + for (var itemData in sakeItemsJson) { + final data = itemData as Map; + + // JSONからSakeItemオブジェクトを再構築 + final item = SakeItem( + id: data['id'] as String, + displayData: DisplayData( + name: data['displayData']['name'] as String, + brewery: data['displayData']['brewery'] as String, + prefecture: data['displayData']['prefecture'] as String, + catchCopy: data['displayData']['catchCopy'] as String?, + imagePaths: List.from(data['displayData']['imagePaths'] as List), + rating: data['displayData']['rating'] as double?, + ), + hiddenSpecs: HiddenSpecs( + description: data['hiddenSpecs']['description'] as String?, + tasteStats: Map.from(data['hiddenSpecs']['tasteStats'] as Map), + flavorTags: List.from(data['hiddenSpecs']['flavorTags'] as List), + sweetnessScore: data['hiddenSpecs']['sweetnessScore'] as double?, + bodyScore: data['hiddenSpecs']['bodyScore'] as double?, + ), + userData: UserData( + isFavorite: data['userData']['isFavorite'] as bool, + isUserEdited: data['userData']['isUserEdited'] as bool, + price: data['userData']['price'] as int?, + costPrice: data['userData']['costPrice'] as int?, + markup: data['userData']['markup'] as double, + priceVariants: data['userData']['priceVariants'] != null + ? Map.from(data['userData']['priceVariants'] as Map) + : null, + ), + gamification: Gamification( + ponPoints: data['gamification']['ponPoints'] as int, + ), + metadata: Metadata( + createdAt: DateTime.parse(data['metadata']['createdAt'] as String), + aiConfidence: data['metadata']['aiConfidence'] as int?, + ), + itemType: data['itemType'] == 'set' ? ItemType.set : ItemType.sake, + ); + + // IDを保持するためにput()を使用(add()は新しいキーを生成してしまう) + await sakeBox.put(item.id, item); + } + print('✅ SakeItemsを復元しました(${sakeItemsJson.length}件)'); + // UI更新のためにわずかに待機 + await Future.delayed(const Duration(milliseconds: 500)); + } + + // 3. settings.jsonを検索 (ルートまたはサブディレクトリ) + File? settingsFile; + try { + settingsFile = potentialFiles.firstWhere((f) => path.basename(f.path) == 'settings.json'); + } catch (e) { + print('⚠️ settings.json が見つかりません (スキップ)'); + } + + if (settingsFile != null && await settingsFile.exists()) { + final settingsJson = json.decode(await settingsFile.readAsString()) as Map; + final settingsBox = Hive.box('settings'); + + await settingsBox.clear(); + for (var entry in settingsJson.entries) { + await settingsBox.put(entry.key, entry.value); + } + print('✅ 設定を復元しました'); + } + + // 4. 画像ファイルを復元 (sake_items.jsonと同じ階層のimagesフォルダを探す) + if (sakeItemsFile != null) { + final parentDir = sakeItemsFile.parent; + final imagesDir = Directory(path.join(parentDir.path, 'images')); + + if (await imagesDir.exists()) { + final appDir = await getApplicationDocumentsDirectory(); + final imageFiles = imagesDir.listSync(); + + for (var imageFile in imageFiles) { + if (imageFile is File) { + final fileName = path.basename(imageFile.path); + await imageFile.copy(path.join(appDir.path, fileName)); + } + } + print('✅ 画像ファイルを復元しました(${imageFiles.length}ファイル)'); + } + } + + // 5. 一時ディレクトリを削除 + await extractDir.delete(recursive: true); + + print('✅ データの復元が完了しました'); + return true; + } catch (error) { + print('❌ 復元処理エラー: $error'); + // スタックトレースも出す + print(error); + if (error is Error) { + print(error.stackTrace); + } + return false; + } + } + + /// Google Driveにバックアップファイルが存在するか確認 + Future hasBackupOnDrive() async { + try { + final account = _googleSignIn.currentUser; + if (account == null) return false; + + final authClient = await _googleSignIn.authenticatedClient(); + if (authClient == null) return false; + + final driveApi = drive.DriveApi(authClient); + + final fileList = await driveApi.files.list( + q: "name = '$backupFileName' and trashed = false", + spaces: 'drive', + ); + + return fileList.files != null && fileList.files!.isNotEmpty; + } catch (error) { + print('❌ バックアップ確認エラー: $error'); + return false; + } + } +} diff --git a/lib/services/gemini_service.dart b/lib/services/gemini_service.dart new file mode 100644 index 0000000..c0cbbac --- /dev/null +++ b/lib/services/gemini_service.dart @@ -0,0 +1,378 @@ +import 'dart:io'; +import 'dart:convert'; +import 'package:google_generative_ai/google_generative_ai.dart'; +import 'package:flutter/foundation.dart'; +import '../secrets.dart'; + +class GeminiService { + late final GenerativeModel _model; + + // レート制限対策: 最後のAPI呼び出し時刻を記録 + static DateTime? _lastApiCallTime; + static const Duration _minApiInterval = Duration(seconds: 5); // 最低5秒間隔 + + // モデル選択: 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() { + _model = GenerativeModel( + model: _modelName, + apiKey: Secrets.geminiApiKey, + // 安全設定: 日本酒情報なので制限を緩和 + safetySettings: [ + SafetySetting(HarmCategory.harassment, HarmBlockThreshold.none), + SafetySetting(HarmCategory.hateSpeech, HarmBlockThreshold.none), + ], + ); + } + + 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; + } + } + + Future analyzeSakeHybrid(String extractedText, List 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」になるような誤字や脱落が含まれますが、 +**添付の画像で実際の表記を確認し、正しい情報を推測・補完**してください。 + +特に以下の点に注目して分析してください: +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}'); + } + + final jsonText = text.trim() + .replaceAll('```json', '') + .replaceAll('```', '') + .trim(); + + final Map json = jsonDecode(jsonText); + + return SakeAnalysisResult.fromJson(json); + + } catch (e) { + _handleError(e); + 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 +class SakeAnalysisResult { + final String? name; + final String? brand; + final String? prefecture; + final String? type; + final String? description; + final String? catchCopy; + final int? confidenceScore; + final List flavorTags; + final Map tasteStats; + + SakeAnalysisResult({ + this.name, + this.brand, + this.prefecture, + this.type, + this.description, + this.catchCopy, + this.confidenceScore, + this.flavorTags = const [], + this.tasteStats = const {}, + }); + + factory SakeAnalysisResult.fromJson(Map json) { + // Helper to extract int from map safely + Map stats = {}; + if (json['tasteStats'] is Map) { + final map = json['tasteStats'] as Map; + stats = map.map((key, value) => MapEntry(key.toString(), (value as num?)?.toInt() ?? 3)); + } + + return SakeAnalysisResult( + name: json['name'] as String?, + brand: json['brand'] as String?, + prefecture: json['prefecture'] as String?, + type: json['type'] as String?, + description: json['description'] as String?, + catchCopy: json['catchCopy'] as String?, + confidenceScore: json['confidenceScore'] as int?, + flavorTags: (json['flavorTags'] as List?)?.map((e) => e.toString()).toList() ?? [], + tasteStats: stats, + ); + } +} diff --git a/lib/services/image_compression_service.dart b/lib/services/image_compression_service.dart new file mode 100644 index 0000000..41c9fab --- /dev/null +++ b/lib/services/image_compression_service.dart @@ -0,0 +1,107 @@ +import 'dart:io'; +import 'dart:typed_data'; +import 'package:flutter/material.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:path/path.dart' as path; + +/// 画像圧縮サービス +/// Gemini APIのトークン消費を削減するため、画像を最適化します +class ImageCompressionService { + /// 最大画像サイズ(長辺): 1024px + /// Geminiは高解像度でなくても十分な認識精度があります + static const int maxDimension = 1024; + + /// JPEG品質: 85% (品質と容量のバランス) + static const int jpegQuality = 85; + + /// 画像を圧縮してGemini API用に最適化 + /// + /// [sourcePath] 元画像のパス + /// [targetPath] 圧縮後の保存先パス(nullの場合は自動生成) + /// + /// 戻り値: 圧縮後の画像パス + static Future compressForGemini(String sourcePath, {String? targetPath}) async { + try { + final File sourceFile = File(sourcePath); + if (!await sourceFile.exists()) { + throw Exception('Source image not found: $sourcePath'); + } + + // 画像をデコード + final Uint8List imageBytes = await sourceFile.readAsBytes(); + final image = await decodeImageFromList(imageBytes); + + final int originalWidth = image.width; + final int originalHeight = image.height; + + // リサイズが不要な場合はそのまま返す + if (originalWidth <= maxDimension && originalHeight <= maxDimension) { + debugPrint('Image already optimized: ${originalWidth}x${originalHeight}'); + return sourcePath; + } + + // アスペクト比を保ったままリサイズ計算 + double scale; + if (originalWidth > originalHeight) { + scale = maxDimension / originalWidth; + } else { + scale = maxDimension / originalHeight; + } + + final int newWidth = (originalWidth * scale).round(); + final int newHeight = (originalHeight * scale).round(); + + debugPrint('Compressing image: ${originalWidth}x${originalHeight} -> ${newWidth}x${newHeight}'); + + // Flutter標準の画像処理では詳細なリサイズができないため、 + // 代わりにファイルサイズ削減のみ実施 + // (本格的なリサイズにはimage packageなどが必要) + + // 保存先パス決定 + final String outputPath = targetPath ?? await _generateCompressedPath(sourcePath); + + // 元のファイルをコピー(簡易実装) + // TODO: 本格的な実装ではimage packageを使用してリサイズ + await sourceFile.copy(outputPath); + + final compressedFile = File(outputPath); + final compressedSize = await compressedFile.length(); + final originalSize = await sourceFile.length(); + + debugPrint('Compression result: ${(originalSize / 1024).toStringAsFixed(1)}KB -> ${(compressedSize / 1024).toStringAsFixed(1)}KB'); + + return outputPath; + + } catch (e) { + debugPrint('Image compression error: $e'); + // エラー時は元のパスを返す(フォールバック) + return sourcePath; + } + } + + /// 圧縮画像の保存先パスを生成 + static Future _generateCompressedPath(String sourcePath) async { + final directory = await getApplicationDocumentsDirectory(); + final fileName = path.basenameWithoutExtension(sourcePath); + final extension = path.extension(sourcePath); + return path.join(directory.path, '${fileName}_compressed$extension'); + } + + /// ファイルサイズを取得(デバッグ用) + static Future getFileSize(String filePath) async { + final file = File(filePath); + return await file.length(); + } + + /// ファイルサイズを人間が読みやすい形式で取得 + static Future getFileSizeString(String filePath) async { + final size = await getFileSize(filePath); + if (size < 1024) { + return '$size B'; + } else if (size < 1024 * 1024) { + return '${(size / 1024).toStringAsFixed(1)} KB'; + } else { + return '${(size / (1024 * 1024)).toStringAsFixed(1)} MB'; + } + } +} diff --git a/lib/services/migration_service.dart b/lib/services/migration_service.dart new file mode 100644 index 0000000..8c466f2 --- /dev/null +++ b/lib/services/migration_service.dart @@ -0,0 +1,70 @@ +import 'package:flutter/foundation.dart'; // debugPrint +import 'package:hive_flutter/hive_flutter.dart'; +import '../models/sake_item.dart'; + +class MigrationService { + static const String _boxName = 'sake_items'; + static const String _backupBoxName = 'sake_items_backup'; + + /// Runs the migration process with safety backup. + /// Should be called after Hive.init and Adapter registration, but before app UI loads. + static Future runMigration() async { + debugPrint('[Migration] Starting Phase 0 Migration...'); + + // 1. Open Boxes + final box = await Hive.openBox(_boxName); + + // 2. Backup Strategy + try { + final backupBox = await Hive.openBox(_backupBoxName); + + if (backupBox.isEmpty && box.isNotEmpty) { + debugPrint('[Migration] detailed backup started...'); + // Copy all + for (var key in box.keys) { + final SakeItem? item = box.get(key); + if (item != null) { + await backupBox.put(key, item.copyWith()); + } + } + debugPrint('[Migration] Backup completed. ${backupBox.length} items secured.'); + } else { + debugPrint('[Migration] Backup skipped (Existing backup found or Empty source).'); + } + + // Close backup to ensure flush + await backupBox.close(); + + } catch (e) { + debugPrint('[Migration] CRITICAL ERROR during Backup: $e'); + // If backup fails, do we abort? + // Yes, abort migration to be safe. + return; + } + + // 3. Migration (In-Place) + int migratedCount = 0; + for (var key in box.keys) { + final SakeItem? item = box.get(key); + if (item != null) { + try { + // ensureMigrated checks if displayData is null. + // If null, it populates it from legacy fields. + bool performed = item.ensureMigrated(); + if (performed) { + await item.save(); // Persist the new structure (DisplayData, etc) + migratedCount++; + } + } catch (e) { + debugPrint('[Migration] Error migrating item $key: $e'); + } + } + } + + if (migratedCount > 0) { + debugPrint('[Migration] Successfully migrated $migratedCount items to Schema v2.0.'); + } else { + debugPrint('[Migration] No items needed migration.'); + } + } +} diff --git a/lib/services/ocr_service.dart b/lib/services/ocr_service.dart new file mode 100644 index 0000000..b3f2bc6 --- /dev/null +++ b/lib/services/ocr_service.dart @@ -0,0 +1,36 @@ +import 'package:flutter/foundation.dart'; +import 'package:google_mlkit_text_recognition/google_mlkit_text_recognition.dart'; + +class OcrService { + late final TextRecognizer _textRecognizer; + + OcrService() { + _textRecognizer = TextRecognizer(script: TextRecognitionScript.japanese); + } + + Future extractText(String imagePath) async { + try { + final inputImage = InputImage.fromFilePath(imagePath); + final RecognizedText recognizedText = await _textRecognizer.processImage(inputImage); + return recognizedText.text; + } catch (e) { + debugPrint('Japanese OCR Error: $e'); + // Fallback to Latin script if Japanese model fails + try { + final latinRecognizer = TextRecognizer(script: TextRecognitionScript.latin); + final inputImage = InputImage.fromFilePath(imagePath); + final RecognizedText recognizedText = await latinRecognizer.processImage(inputImage); + await latinRecognizer.close(); + debugPrint('Fallback to Latin OCR successful'); + return recognizedText.text; + } catch (e2) { + debugPrint('Latin OCR also failed: $e2'); + return ''; // Return empty string on error to allow fallback to image analysis + } + } + } + + void dispose() { + _textRecognizer.close(); + } +} diff --git a/lib/services/pdf_service.dart b/lib/services/pdf_service.dart new file mode 100644 index 0000000..2ae0a28 --- /dev/null +++ b/lib/services/pdf_service.dart @@ -0,0 +1,481 @@ +import 'dart:io'; +import 'dart:typed_data'; +import 'package:flutter/foundation.dart'; +import 'package:pdf/pdf.dart'; +import 'package:pdf/widgets.dart' as pw; +import 'package:printing/printing.dart'; +import 'package:qr/qr.dart'; +import '../models/sake_item.dart'; +import 'pricing_calculator.dart'; +import 'pricing_helper.dart'; + +class PdfService { + static Future generateMenuPdf( + List items, { + required String title, + required String date, + required bool includePhoto, + required bool includePoem, + required bool includeChart, + required bool includePrice, + required bool includeDate, + required bool includeQr, // New parameter + String pdfSize = 'a4', // 'a4', 'a5', 'b5' + bool isMonochrome = false, + bool isPortrait = true, + double density = 1.2, + }) async { + final pdf = pw.Document(); + + // Font Loading + final font = await PdfGoogleFonts.notoSansJPRegular(); + + // Prepare Images (Monochrome processing handled in View or here? + // Ideally Printing package handles output color, but for "Preview" we might want processing. + // But Printing package's `PdfPreview` usually handles grayscale display if pdf is standard. + // However, user requested "Monochrome Preview Fix", meaning usually `PdfPreview` shows color even if printer driver will default to bw. + // We can pre-process images to grayscale here if isMonochrome is true. + + final Map itemImages = {}; + for (var item in items) { + if (item.displayData.imagePaths.isNotEmpty) { + final file = File(item.displayData.imagePaths.first); + if (await file.exists()) { + try { + final bytes = await file.readAsBytes(); + // In a real app we might use `image` package to grayscale here. + // For now, let's load raw bytes. + // Printing package's `PdfPreview` has `build(...)` that returns bytes. + itemImages[item.id] = pw.MemoryImage(bytes); + } catch (e) { + debugPrint('Error loading image for ${item.displayData.name}: $e'); + itemImages[item.id] = null; + } + } + } + } + + final PdfPageFormat rawFormat = _getPageFormat(pdfSize); + final PdfPageFormat pageFormat = isPortrait ? rawFormat : rawFormat.landscape; + + // Density Calculation + // Base items per page (Normal Density 1.0) + // A4: 8, A5: 4, B5: 6 (Roughly) + // With Density 1.2 (High) -> +20% items + // With Density 1.5 -> +50% items + + // Let's define base items per page for "1.0" + int baseItems = 0; + switch (pdfSize) { + case 'a5': baseItems = 4; break; + case 'b5': baseItems = 6; break; + case 'a4': default: baseItems = 8; break; + } + + final int itemsPerPage = (baseItems * density).round().clamp(1, 20); // Safety clamp + final int crossAxisCount = _getCrossAxisCount(pdfSize); // 1 or 2 + + for (var i = 0; i < items.length; i += itemsPerPage) { + final chunk = items.skip(i).take(itemsPerPage).toList(); + final double titleFontSize = _getTitleFontSize(pdfSize); + final double dateFontSize = _getDateFontSize(pdfSize); + final double margin = _getPageMargin(pdfSize); + + pdf.addPage( + pw.Page( + pageFormat: pageFormat, + margin: pw.EdgeInsets.all(margin), + theme: pw.ThemeData.withFont(base: font), + build: (context) { + return pw.Column( + children: [ + // Header + pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + // Title Row with Tax Label + pw.Row( + crossAxisAlignment: pw.CrossAxisAlignment.start, + mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, + children: [ + pw.Expanded( + child: pw.Container( + decoration: pw.BoxDecoration( + border: pw.Border(bottom: pw.BorderSide(color: PdfColors.grey400, width: 1.5)), + ), + padding: const pw.EdgeInsets.only(bottom: 4), + child: pw.Text( + title, + style: pw.TextStyle(fontSize: titleFontSize, font: font, fontWeight: pw.FontWeight.bold), + ), + ), + ), + // Tax Included Label (Top Right) + if (includePrice) ...[ + pw.SizedBox(width: 8), + pw.Container( + padding: const pw.EdgeInsets.symmetric(horizontal: 6, vertical: 3), + decoration: pw.BoxDecoration( + border: pw.Border.all(color: PdfColors.grey500), + borderRadius: pw.BorderRadius.circular(4), + ), + child: pw.Text( + '税込価格', + style: pw.TextStyle( + fontSize: dateFontSize * 0.95, + font: font, + color: PdfColors.grey700, + ), + ), + ), + ], + ], + ), + // Date (if included) + if (includeDate && date.isNotEmpty) + pw.Padding( + padding: const pw.EdgeInsets.only(bottom: 4, top: 4), + child: pw.Text(date, style: pw.TextStyle(fontSize: dateFontSize, font: font)), + ), + ], + ), + pw.SizedBox(height: 4), + + pw.Expanded( + child: pw.Column( + children: [ + for (var r = 0; r < (itemsPerPage / crossAxisCount).ceil(); r++) + pw.Expanded( + child: pw.Row( + crossAxisAlignment: pw.CrossAxisAlignment.stretch, + children: [ + for (var c = 0; c < crossAxisCount; c++) ...[ + () { + final indexInChunk = r * crossAxisCount + c; + if (indexInChunk < chunk.length) { + return pw.Expanded( + child: _buildMenuItem( + chunk[indexInChunk], + includePhoto ? itemImages[chunk[indexInChunk].id] : null, + includePoem, + includeChart, + includePrice, + includeQr, + font, + pdfSize, + density, // Pass density to scale standard sizes if needed + isMonochrome, + ), + ); + } else { + return pw.Expanded(child: pw.SizedBox()); + } + }(), + + if (c < crossAxisCount - 1) + pw.SizedBox(width: _getGridSpacing(pdfSize)), + ], + ], + ), + ), + ], + ), + ), + ], + ); + }, + ), + ); + } + return pdf.save(); + } + + static pw.Widget _buildMenuItem( + SakeItem item, + pw.MemoryImage? image, + bool includePoem, + bool includeChart, + bool includePrice, + bool includeQr, + pw.Font font, + String pdfSize, + double density, + bool isMonochrome, + ) { + // When absolute density increases (more items), font sizes might need specific scaling + // IF the container becomes too small. + // However, `pw.Expanded` handles height distribution. + // If text overflows, we might need auto-scaling. + // For now, let's keep fonts standard but reduce padding slightly if density is high. + + final price = includePrice ? PricingCalculator.calculatePrice(item) : 0; + + // Scale down fonts slightly if density > 1.2 + double scale = 1.0; + if (density > 1.2) scale = 0.9; + if (density > 1.4) scale = 0.85; + + final double nameFontSize = _getNameFontSize(pdfSize) * scale; + final double priceFontSize = _getPriceFontSize(pdfSize) * scale; + final double infoFontSize = _getInfoFontSize(pdfSize) * scale; + final double poemFontSize = _getPoemFontSize(pdfSize) * scale; + final double imageSize = _getImageSize(pdfSize) * scale; + // Extreme compaction for high density + final double containerPadding = density > 1.0 ? 2 : _getContainerPadding(pdfSize) * scale; + + // Grayscale Filter for Image + pw.ImageProvider? processedImage = image; + + // For monochrome logic: + // We can't easily filter the image bytes without decode, but text colors are handled. + + final textColor = isMonochrome ? PdfColors.black : PdfColors.black; + final accentColor = isMonochrome ? PdfColors.grey800 : PdfColors.brown400; + final subColor = isMonochrome ? PdfColors.grey700 : PdfColors.grey700; + + return pw.Container( + decoration: pw.BoxDecoration( + border: pw.Border(bottom: pw.BorderSide(color: PdfColors.grey300, width: 0.5)), + ), + padding: pw.EdgeInsets.symmetric(vertical: containerPadding, horizontal: containerPadding), + child: pw.Row( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + // Left: Photo + if (image != null) ...[ + pw.Container( + width: imageSize, + height: imageSize, + decoration: pw.BoxDecoration( + borderRadius: pw.BorderRadius.circular(4), + image: pw.DecorationImage(image: image, fit: pw.BoxFit.cover), + ), + ), + pw.SizedBox(width: 10), // Increased Gap + ], + + // Right: Content + pw.Expanded( + child: pw.Row( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + // Left Column: Name, Brewery, Catch Copy + pw.Expanded( + child: pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.start, + mainAxisAlignment: pw.MainAxisAlignment.start, + children: [ + // 1. Name + pw.Text( + item.displayData.name, + style: pw.TextStyle(fontSize: nameFontSize, fontWeight: pw.FontWeight.bold, font: font), + maxLines: 2, + ), + + // 2. Brewery / Prefecture + if (item.itemType != ItemType.set) ...[ + pw.SizedBox(height: 2), + pw.Text( + '${item.displayData.brewery} / ${item.displayData.prefecture}', + style: pw.TextStyle(fontSize: infoFontSize, color: subColor, font: font), + maxLines: 1, + ), + ], + + // 3. Catch Copy (Poem) + if (includePoem && item.displayData.catchCopy != null) ...[ + pw.SizedBox(height: 4), + pw.Container( + padding: const pw.EdgeInsets.only(left: 6), + decoration: pw.BoxDecoration( + border: pw.Border(left: pw.BorderSide(color: isMonochrome ? PdfColors.grey500 : PdfColors.grey400, width: 1.5)) + ), + child: pw.Text( + item.displayData.catchCopy!, + style: pw.TextStyle(fontSize: poemFontSize, font: font, fontStyle: pw.FontStyle.italic, color: accentColor), + maxLines: 2, + tightBounds: true, + ), + ), + ], + ], + ), + ), + + // Right Column: Price (right-aligned) + if (includePrice) ...[ + pw.SizedBox(width: 8), + _buildCompactPriceTag(item, price, priceFontSize, font), + ], + + // 6. QR Code (Data-in-QR) + if (includeQr) ...[ + pw.SizedBox(width: 8), + _buildQrCode(item), + ] + ], + ), + ), + ], + ) + ); + } + + static pw.Widget _buildQrCode(SakeItem item) { + // Compact JSON for QR + final jsonStr = item.toQrJson(); + + // "Cute" QR Rendering using CustomPaint (Vector) to draw circles + final qrCode = QrCode.fromData( + data: jsonStr, + errorCorrectLevel: QrErrorCorrectLevel.M, + ); + final qrImage = QrImage(qrCode); + + return pw.Container( + width: 40, + height: 40, + child: pw.CustomPaint( + size: const PdfPoint(40, 40), + painter: (PdfGraphics canvas, PdfPoint size) { + final double pixelSize = size.x / qrImage.moduleCount; + + for (var x = 0; x < qrImage.moduleCount; x++) { + for (var y = 0; y < qrImage.moduleCount; y++) { + if (qrImage.isDark(y, x)) { + final double cx = x * pixelSize + pixelSize / 2; + final double cy = y * pixelSize + pixelSize / 2; + // Increase radius ratio for cuter look (0.9 -> 1.0 or slightly overlaps? 0.85 for cleaner dots) + // Let's use 1.0 for full circles that touch (cute) + final double radius = pixelSize / 2 * 0.95; + + // Corner finding logic for "Eyes" (Position Detection Patterns) + // Top-Left: (0-6, 0-6). Top-Right: (last-7 to last, 0-6). Bottom-Left: (0-6, last-7 to last) + // We can color them differently or keep uniformity. + // For "Cute", let's keep it uniform black circles but sharp. + + canvas.drawEllipse(cx, cy, radius, radius); + canvas.setFillColor(PdfColors.black); + canvas.fillPath(); + } + } + } + } + ), + ); + } + + + + + + // Helper for multi-price display (used in new layout) + static pw.Widget _buildMultiPriceTag(Map priceVariants, double fontSize, pw.Font font) { + return pw.Wrap( + spacing: 8, + runSpacing: 2, + children: priceVariants.entries.take(3).map((e) => + pw.RichText( + text: pw.TextSpan( + children: [ + pw.TextSpan( + text: '${e.key} ', + style: pw.TextStyle(font: font, fontSize: fontSize * 0.75, color: PdfColors.grey700), + ), + pw.TextSpan( + text: '${PricingHelper.formatPrice(e.value)}円', + style: pw.TextStyle(font: font, fontSize: fontSize, fontWeight: pw.FontWeight.bold), + ), + ], + ), + ), + ).toList(), + ); + } + + static pw.Widget _buildCompactPriceTag(SakeItem item, int calcPrice, double fontSize, pw.Font font) { + if (item.userData.priceVariants != null && item.userData.priceVariants!.isNotEmpty) { + // Multi-price: Column aligned to right + return pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.end, + children: item.userData.priceVariants!.entries.take(3).map((e) => + pw.RichText( + text: pw.TextSpan( + children: [ + pw.TextSpan(text: '${e.key} ', style: pw.TextStyle(font: font, fontSize: fontSize * 0.7, color: PdfColors.grey700)), + pw.TextSpan(text: '${PricingHelper.formatPrice(e.value)}円', style: pw.TextStyle(font: font, fontSize: fontSize, fontWeight: pw.FontWeight.bold)), + ] + ) + ) + ).toList(), + ); + } else if (calcPrice > 0) { + return pw.Text( + '${PricingHelper.formatPrice(calcPrice)}円', + style: pw.TextStyle(font: font, fontSize: fontSize, fontWeight: pw.FontWeight.bold), + ); + } + return pw.SizedBox(); + } + + static pw.Widget _buildStarRating(String label, int value, pw.Font font, String pdfSize) { + final double starFontSize = _getStarFontSize(pdfSize); + return pw.Row( + crossAxisAlignment: pw.CrossAxisAlignment.center, + children: [ + pw.Text(label, style: pw.TextStyle(fontSize: starFontSize, font: font, color: PdfColors.grey600)), + pw.SizedBox(width: 2), + pw.Text('★' * value + '☆' * (5 - value), style: pw.TextStyle(fontSize: starFontSize, font: font, color: PdfColors.orange400)), + ] + ); + } + + + static PdfPageFormat _getPageFormat(String size) { + switch (size) { + case 'a5': return PdfPageFormat.a5; // 148 x 210 + case 'b5': return PdfPageFormat(182 * PdfPageFormat.mm, 257 * PdfPageFormat.mm); // 182 x 257 (Portrait) + case 'a4': default: return PdfPageFormat.a4; // 210 x 297 + } + } + + // TWEAKED SIZES For "Standard" Template + static double _getTitleFontSize(String pdfSize) => pdfSize == 'a5' ? 16.0 : 20.0; + static double _getDateFontSize(String pdfSize) => pdfSize == 'a5' ? 9.0 : 10.0; + static double _getNameFontSize(String pdfSize) => pdfSize == 'a5' ? 12.0 : (pdfSize == 'b5' ? 12.0 : 14.0); + static double _getPriceFontSize(String pdfSize) => pdfSize == 'a5' ? 12.0 : (pdfSize == 'b5' ? 12.0 : 14.0); + static double _getInfoFontSize(String pdfSize) => pdfSize == 'a5' ? 8.0 : (pdfSize == 'b5' ? 8.0 : 9.0); + static double _getPoemFontSize(String pdfSize) => pdfSize == 'a5' ? 8.5 : (pdfSize == 'b5' ? 8.5 : 10.0); + static double _getStarFontSize(String pdfSize) => pdfSize == 'a5' ? 6.0 : 7.0; + + static double _getImageSize(String pdfSize) { + switch (pdfSize) { + case 'a5': return 50.0; // Increased from 40 + case 'b5': return 50.0; + case 'a4': default: return 60.0; + } + } + + static double _getContainerPadding(String pdfSize) => 6.0; + static double _getPageMargin(String pdfSize) => 10.0 * PdfPageFormat.mm; + static double _getGridSpacing(String pdfSize) => 8.0; + + static int _getItemsPerPage(String pdfSize) { + switch (pdfSize) { + case 'a5': return 5; // Portrait, 1 col, 5 items + case 'b5': return 8; + case 'a4': default: return 10; + } + } + + static int _getCrossAxisCount(String pdfSize) { + switch (pdfSize) { + case 'a5': return 1; + case 'b5': return 2; + case 'a4': default: return 2; + } + } + + // Unused but kept if needed for reference + static double _getChildAspectRatio(String pdfSize) => 1.0; +} diff --git a/lib/services/pricing_calculator.dart b/lib/services/pricing_calculator.dart new file mode 100644 index 0000000..5c54a31 --- /dev/null +++ b/lib/services/pricing_calculator.dart @@ -0,0 +1,40 @@ + +import '../models/sake_item.dart'; +import 'pricing_helper.dart'; + +class PricingCalculator { + // Round up to nearest 50 yen + static int roundUpTo50(double price) { + if (price <= 0) return 0; + return ((price / 50).ceil() * 50).toInt(); + } + + // Calculate Selling Price + static int calculatePrice(SakeItem item, {double? globalMarkup}) { + // 1. Manual Override takes precedence + if (item.userData.price != null) { + return item.userData.price!; + } + + // 2. If no cost, cannot calculate + if (item.userData.costPrice == null) { + return 0; + } + + // 3. Determine Markup + // If globalMarkup is provided (e.g. from Settings preview), use it? + // Or normally use item.markup. + final double markup = globalMarkup ?? item.userData.markup; + + // 4. Calculate + final double rawPrice = item.userData.costPrice! * markup; + + // 5. Rounding + return roundUpTo50(rawPrice); + } + + // 価格フォーマット (PricingHelperに委譲) + static String formatPrice(int price) { + return PricingHelper.formatPrice(price); + } +} diff --git a/lib/services/pricing_helper.dart b/lib/services/pricing_helper.dart new file mode 100644 index 0000000..8a79b6b --- /dev/null +++ b/lib/services/pricing_helper.dart @@ -0,0 +1,94 @@ +/// 価格設定のヘルパー関数群 +/// +/// 店員の感覚に寄り添った価格処理を提供します: +/// - スマート丸め処理 (キリの良い価格に自動調整) +/// - サイズ別価格の自動計算 +/// - 価格表示フォーマット (カンマ区切り、円表記) +class PricingHelper { + /// サイズ別の価格比率 + /// + /// 店員が感覚的に使っている「一合を基準にした比率」 + static const sizeRatios = { + '45ml': 0.40, // 一合の約1/4 + '90ml': 0.65, // 一合の半分弱 + '180ml': 1.0, // 基準 (一合) + }; + + /// スマート丸め処理 + /// + /// 店員が手作業でやる「端数を切る」処理を自動化。 + /// 価格帯に応じて、キリの良い単位に丸めます: + /// - 500円未満: 50円単位 (例: 720円 → 700円) + /// - 500円以上: 100円単位 (例: 1,170円 → 1,200円) + /// + /// [rawPrice] 計算された生の価格 + /// 戻り値: 丸められた価格 + static int smartRound(double rawPrice) { + if (rawPrice <= 0) return 0; + + if (rawPrice < 500) { + // 500円未満 → 50円単位 + return ((rawPrice / 50).round() * 50).toInt(); + } else { + // 500円以上 → 100円単位 + return ((rawPrice / 100).round() * 100).toInt(); + } + } + + /// サイズ別価格の計算 + /// + /// 一合価格を基準に、各サイズの価格を自動計算します。 + /// + /// [basePrice] 一合 (180ml) の価格 + /// [size] サイズ (45ml, 90ml, 180ml) + /// 戻り値: 計算・丸め処理された価格 + static int calculateSizePrice(int basePrice, String size) { + final ratio = sizeRatios[size] ?? 1.0; + final rawPrice = basePrice * ratio; + return smartRound(rawPrice); + } + + /// 価格フォーマット (カンマ区切り) + /// + /// 内部データ (int) を表示用文字列に変換します: + /// - 3桁ごとにカンマを挿入 + /// - 「円」は付けない (表示側で追加) + /// + /// [price] 価格 (int) + /// 戻り値: カンマ区切りの文字列 (例: "1,800") + static String formatPrice(int price) { + if (price <= 0) return '0'; + return price.toString().replaceAllMapped( + RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'), + (Match m) => '${m[1]},', + ); + } + + /// 価格パース (カンマ除去) + /// + /// ユーザー入力 (カンマ付き文字列) を int に変換します。 + /// + /// [priceString] 価格文字列 (例: "1,800" or "1800") + /// 戻り値: パースされた価格 (失敗時は null) + static int? parsePrice(String priceString) { + final cleaned = priceString.replaceAll(',', '').trim(); + return int.tryParse(cleaned); + } + + /// サイズ表示名 + /// + /// 内部ID (45ml) を表示用の名称に変換します。 + /// + /// [size] サイズID + /// 戻り値: 表示名 (例: "45ml") + static String getSizeDisplayName(String size) { + // 現状はそのまま返すが、将来的に「グラス (45ml)」などに拡張可能 + return size; + } + + /// 利用可能なサイズ一覧 + /// + /// MVPで提供するサイズの一覧。 + /// デモでは3種類に絞る。 + static List get availableSizes => ['45ml', '90ml', '180ml']; +} diff --git a/lib/services/sake_recommendation_service.dart b/lib/services/sake_recommendation_service.dart new file mode 100644 index 0000000..34525ce --- /dev/null +++ b/lib/services/sake_recommendation_service.dart @@ -0,0 +1,155 @@ +import 'dart:math'; +import '../models/sake_item.dart'; + +/// 日本酒レコメンデーションサービス +/// AIを使わずローカルで類似度計算(トークン消費0) +class SakeRecommendationService { + /// 五味チャートのコサイン類似度を計算 + /// 戻り値: 0.0(全く異なる)〜 1.0(完全一致) + static double calculateTasteSimilarity( + Map tasteA, + Map tasteB, + ) { + final keys = ['aroma', 'sweetness', 'acidity', 'bitterness', 'body']; + + double dotProduct = 0; + double normA = 0; + double normB = 0; + + for (var key in keys) { + final valA = (tasteA[key] ?? 3).toDouble(); + final valB = (tasteB[key] ?? 3).toDouble(); + + dotProduct += valA * valB; + normA += valA * valA; + normB += valB * valB; + } + + if (normA == 0 || normB == 0) return 0; + + return dotProduct / (sqrt(normA) * sqrt(normB)); + } + + /// レコメンドスコアを計算 + /// + /// スコアリング基準: + /// - 同じ酒蔵: +10点 + /// - 同じ都道府県: +5点 + /// - 共通タグ: +2点/タグ + /// - 五味類似度: +15点 × 類似度(0-1) + static double calculateScore(SakeItem target, SakeItem candidate) { + double score = 0; + + // 1. 酒蔵つながり + if (candidate.displayData.brewery == target.displayData.brewery && + target.displayData.brewery != '不明' && + candidate.id != target.id) { + score += 10; + } + + // 2. 都道府県つながり + if (candidate.displayData.prefecture == target.displayData.prefecture && + target.displayData.prefecture != '不明' && + candidate.id != target.id) { + score += 5; + } + + // 3. タグつながり + final commonTags = candidate.hiddenSpecs.flavorTags + .where((tag) => target.hiddenSpecs.flavorTags.contains(tag)) + .toList(); + score += commonTags.length * 2; + + // 4. 五味チャート類似度 + if (target.hiddenSpecs.tasteStats.isNotEmpty && candidate.hiddenSpecs.tasteStats.isNotEmpty) { + final similarity = calculateTasteSimilarity( + target.hiddenSpecs.tasteStats, + candidate.hiddenSpecs.tasteStats, + ); + score += similarity * 15; + } + + return score; + } + + /// レコメンド理由を生成 + static String getRecommendationReason(SakeItem target, SakeItem candidate) { + final reasons = []; + + // 酒蔵つながり + if (candidate.displayData.brewery == target.displayData.brewery && target.displayData.brewery != '不明') { + reasons.add('${target.displayData.brewery}つながり'); + } + + // 都道府県つながり + if (candidate.displayData.prefecture == target.displayData.prefecture && + target.displayData.prefecture != '不明' && + candidate.displayData.brewery != target.displayData.brewery) { + reasons.add('${target.displayData.prefecture}つながり'); + } + + // タグつながり + final commonTags = candidate.hiddenSpecs.flavorTags + .where((tag) => target.hiddenSpecs.flavorTags.contains(tag)) + .take(2) + .toList(); + if (commonTags.isNotEmpty) { + reasons.add(commonTags.join('・')); + } + + // 五味類似度 + if (target.hiddenSpecs.tasteStats.isNotEmpty && candidate.hiddenSpecs.tasteStats.isNotEmpty) { + final similarity = calculateTasteSimilarity( + target.hiddenSpecs.tasteStats, + candidate.hiddenSpecs.tasteStats, + ); + if (similarity > 0.8) { + reasons.add('似た味わい'); + } + } + + return reasons.isEmpty ? 'おすすめ' : reasons.join(' / '); + } + + /// スマートレコメンド(スコア順にソート) + /// + /// [target] 基準となる日本酒 + /// [allItems] 全日本酒リスト + /// [limit] 返す最大件数(デフォルト: 10) + /// + /// 戻り値: (日本酒, スコア, 理由) のリスト + static List getRecommendations({ + required SakeItem target, + required List allItems, + int limit = 10, + }) { + final scored = allItems + .where((item) => item.id != target.id) // 自分自身を除外 + .map((item) { + final score = calculateScore(target, item); + final reason = getRecommendationReason(target, item); + return RecommendedSake( + item: item, + score: score, + reason: reason, + ); + }).where((rec) => rec.score > 0) // スコア0より大きいもののみ + .toList() + ..sort((a, b) => b.score.compareTo(a.score)); // 降順ソート + + return scored.take(limit).toList(); + } +} + +/// レコメンド結果 +class RecommendedSake { + final SakeItem item; + final double score; + final String reason; + + RecommendedSake({ + required this.item, + required this.score, + required this.reason, + }); +} diff --git a/lib/services/shuko_diagnosis_service.dart b/lib/services/shuko_diagnosis_service.dart new file mode 100644 index 0000000..c01b9da --- /dev/null +++ b/lib/services/shuko_diagnosis_service.dart @@ -0,0 +1,139 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../models/sake_item.dart'; +import '../models/schema/sake_taste_stats.dart'; + +final shukoDiagnosisServiceProvider = Provider((ref) => ShukoDiagnosisService()); + +class ShukoDiagnosisService { + ShukoProfile diagnose(List items) { + if (items.isEmpty) { + return ShukoProfile.empty(); + } + + // 1. Calculate Average Stats + double totalAroma = 0; + double totalRichness = 0; + double totalSweetness = 0; + double totalAlcohol = 0; + double totalFruity = 0; + int count = 0; + + for (var item in items) { + final stats = item.hiddenSpecs.sakeTasteStats; + if (stats != null) { + totalAroma += stats.aroma; + totalRichness += stats.richness; + totalSweetness += stats.sweetness; + totalAlcohol += stats.alcoholFeeling; + totalFruity += stats.fruitiness; + count++; + } + } + + if (count == 0) { + return ShukoProfile.empty(); + } + + final avgStats = SakeTasteStats( + aroma: totalAroma / count, + richness: totalRichness / count, + sweetness: totalSweetness / count, + alcoholFeeling: totalAlcohol / count, + fruitiness: totalFruity / count, + ); + + // 2. Determine Title based on dominant traits + final title = _determineTitle(avgStats); + + return ShukoProfile( + title: title.title, + description: title.description, + avgStats: avgStats, + totalSakeCount: items.length, + analyzedCount: count, + ); + } + + ShukoTitle _determineTitle(SakeTasteStats stats) { + // Simple rule-based logic (can be expanded) + + // High Alcohol + Dry (Low Sweetness) + if (stats.alcoholFeeling > 3.5 && stats.sweetness < 2.5) { + return const ShukoTitle( + title: '辛口サムライ', + description: 'キレのある辛口を好む、硬派な日本酒ファン。', + ); + } + + // High Fruitiness + High Sweetness + if (stats.fruitiness > 3.5 && stats.sweetness > 3.0) { + return const ShukoTitle( + title: 'フルーティーマスター', + description: '果実のような香りと甘みを愛する、華やかな飲み手。', + ); + } + + // High Richness + if (stats.richness > 3.8) { + return const ShukoTitle( + title: '旨口探求者', + description: 'お米本来の旨みやコクを重視する、通な舌の持ち主。', + ); + } + + // High Aroma + if (stats.aroma > 4.0) { + return const ShukoTitle( + title: '香りの貴族', + description: '吟醸香など、鼻に抜ける香りを何より楽しむタイプ。', + ); + } + + // Balanced (All around 3) + if (stats.aroma > 2.5 && stats.aroma < 3.5 && + stats.sweetness > 2.5 && stats.sweetness < 3.5) { + return const ShukoTitle( + title: 'バランスの賢者', + description: '偏りなく様々な酒を楽しむ、オールラウンダー。', + ); + } + + // Default + return const ShukoTitle( + title: 'ポシマイ杜氏', + description: '自分だけの好みを探索中の、未来の巨匠。', + ); + } +} + +class ShukoProfile { + final String title; + final String description; + final SakeTasteStats avgStats; + final int totalSakeCount; + final int analyzedCount; + + ShukoProfile({ + required this.title, + required this.description, + required this.avgStats, + required this.totalSakeCount, + required this.analyzedCount, + }); + + factory ShukoProfile.empty() { + return ShukoProfile( + title: '旅の始まり', + description: 'まずは日本酒を記録して、\n自分の好みを発見しましょう。', + avgStats: SakeTasteStats(aroma: 0, richness: 0, sweetness: 0, alcoholFeeling: 0, fruitiness: 0), + totalSakeCount: 0, + analyzedCount: 0, + ); + } +} + +class ShukoTitle { + final String title; + final String description; + const ShukoTitle({required this.title, required this.description}); +} diff --git a/lib/theme/app_theme.dart b/lib/theme/app_theme.dart new file mode 100644 index 0000000..e97774a --- /dev/null +++ b/lib/theme/app_theme.dart @@ -0,0 +1,90 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +class AppTheme { + static const Color posimaiBlue = Color(0xFF376495); + + // Padding Constants + static const double spacingEmpty = 0.0; + static const double spacingTiny = 4.0; + static const double spacingSmall = 8.0; + static const double spacingMedium = 16.0; + static const double spacingLarge = 24.0; + static const double spacingXLarge = 32.0; + + + static ThemeData createTheme(String fontPreference, Brightness brightness) { + final textTheme = (fontPreference == 'serif') + ? GoogleFonts.notoSerifJpTextTheme() + : GoogleFonts.notoSansJpTextTheme(); + + final baseColorScheme = ColorScheme.fromSeed( + seedColor: posimaiBlue, + brightness: brightness, + ); + + // Dark Mode Refinements for Posimai Blue + final colorScheme = (brightness == Brightness.dark) + ? baseColorScheme.copyWith( + primary: const Color(0xFF8AB4F8), // Lighter blue for dark mode visibility + onPrimary: Colors.black, + surface: const Color(0xFF1E1E1E), + ) + : baseColorScheme; + + return ThemeData( + useMaterial3: true, + colorScheme: colorScheme, + textTheme: textTheme.apply( + bodyColor: (brightness == Brightness.dark) ? Colors.white : Colors.black87, + displayColor: (brightness == Brightness.dark) ? Colors.white : Colors.black87, + ).copyWith( + // Ensure headers/labels are visible + titleMedium: TextStyle(color: (brightness == Brightness.dark) ? Colors.white : Colors.black87), + titleSmall: TextStyle(color: (brightness == Brightness.dark) ? Colors.white70 : Colors.black54), + labelLarge: TextStyle(color: (brightness == Brightness.dark) ? Colors.white : Colors.black87), + ), + scaffoldBackgroundColor: (brightness == Brightness.dark) + ? const Color(0xFF121212) + : Colors.white, + + cardTheme: CardThemeData( + elevation: 0, + margin: EdgeInsets.zero, + color: (brightness == Brightness.dark) ? const Color(0xFF1E1E1E) : Colors.white, + ), + + appBarTheme: AppBarTheme( + backgroundColor: (brightness == Brightness.dark) ? const Color(0xFF121212) : null, + foregroundColor: (brightness == Brightness.dark) ? Colors.white : Colors.black87, + actionsIconTheme: IconThemeData( + color: (brightness == Brightness.dark) ? Colors.white : Colors.black87, + ), + iconTheme: IconThemeData( + color: (brightness == Brightness.dark) ? Colors.white : Colors.black87, + ), + ), + + iconTheme: IconThemeData( + color: (brightness == Brightness.dark) ? Colors.white : const Color(0xFF376495), + ), + + /* + dialogTheme: DialogTheme( + backgroundColor: (brightness == Brightness.dark) ? const Color(0xFF2C2C2C) : Colors.white, + titleTextStyle: TextStyle(color: (brightness == Brightness.dark) ? Colors.white : Colors.black, fontSize: 20, fontWeight: FontWeight.bold), + contentTextStyle: TextStyle(color: (brightness == Brightness.dark) ? Colors.white : Colors.black, fontSize: 16), + ), + */ + + navigationBarTheme: NavigationBarThemeData( + backgroundColor: (brightness == Brightness.dark) ? const Color(0xFF1E1E1E) : null, + indicatorColor: (brightness == Brightness.dark) + ? const Color(0xFF376495) + : const Color(0xFF376495).withOpacity(0.25), + // Removed custom MaterialStateProperty text styles to avoid potential type issues for now. + // Default text style usually adapts to brightness if colorScheme.onSurface/onPrimary is set correctly. + ), + ); + } +} diff --git a/lib/widgets/add_set_item_dialog.dart b/lib/widgets/add_set_item_dialog.dart new file mode 100644 index 0000000..66f01db --- /dev/null +++ b/lib/widgets/add_set_item_dialog.dart @@ -0,0 +1,275 @@ +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:hive_flutter/hive_flutter.dart'; // Added for Hive.box +import 'package:image_picker/image_picker.dart'; +import 'package:uuid/uuid.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:path/path.dart' as path; +import '../models/sake_item.dart'; +import '../providers/sake_list_provider.dart'; +import '../theme/app_theme.dart'; +import 'package:lucide_icons/lucide_icons.dart'; + +class AddSetItemDialog extends ConsumerStatefulWidget { + const AddSetItemDialog({super.key}); + + @override + ConsumerState createState() => _AddSetItemDialogState(); +} + +class _AddSetItemDialogState extends ConsumerState { + final _formKey = GlobalKey(); + final _nameController = TextEditingController(); + final _descriptionController = TextEditingController(); + final _priceController = TextEditingController(); + + bool _useDefaultImage = true; + String? _pickedImagePath; + final ImagePicker _picker = ImagePicker(); + + @override + void dispose() { + _nameController.dispose(); + _descriptionController.dispose(); + _priceController.dispose(); + super.dispose(); + } + + Future _pickImage(ImageSource source) async { + try { + final XFile? image = await _picker.pickImage( + source: source, + maxWidth: 1024, + maxHeight: 1024, + imageQuality: 85, + ); + if (image != null) { + setState(() { + _pickedImagePath = image.path; + _useDefaultImage = false; + }); + } + } catch (e) { + // Handle error + } + } + + Future _save() async { + if (!_formKey.currentState!.validate()) return; + + // Save Logic + final name = _nameController.text; + final description = _descriptionController.text; + final price = int.tryParse(_priceController.text) ?? 0; + + // Image handling + List imagePaths = []; + if (!_useDefaultImage && _pickedImagePath != null) { + // Copy to app doc dir + final appDir = await getApplicationDocumentsDirectory(); + final fileName = '${const Uuid().v4()}.jpg'; + final savedImage = await File(_pickedImagePath!).copy(path.join(appDir.path, fileName)); + imagePaths.add(savedImage.path); + } + // Note: If using default image, we leave imagePaths empty. + // The UI will show asset if imagePaths is empty AND itemType is set. + + final newItem = SakeItem( + id: const Uuid().v4(), + itemType: ItemType.set, + displayData: DisplayData( + name: name, + brewery: 'Set Product', // Hidden in UI + prefecture: 'Set', // Hidden in UI + catchCopy: description, // Use catchCopy for description + imagePaths: imagePaths, + ), + userData: UserData( + price: price, + isUserEdited: true, // It is manually created + markup: 1.0, // Default for set + ), + hiddenSpecs: HiddenSpecs(description: description), + metadata: Metadata(createdAt: DateTime.now()), + ); + + // Save to Hive Directly (since sakeListProvider is read-only) + final box = Hive.box('sake_items'); + await box.add(newItem); + + if (mounted) { + Navigator.of(context).pop(); + } + } + + @override + Widget build(BuildContext context) { + return Dialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + child: Container( + padding: const EdgeInsets.all(24), + constraints: const BoxConstraints(maxWidth: 500), + child: SingleChildScrollView( + child: Form( + key: _formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Icon(LucideIcons.package, color: Theme.of(context).colorScheme.primary), // Box icon + const SizedBox(width: 8), + Text( + 'セット商品の登録', + style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold), + ), + ], + ), + const SizedBox(height: 24), + + // Image Section + Center( + child: GestureDetector( + onTap: () { + // Toggle or show picker + _showImageSourceDialog(); + }, + child: Container( + width: 120, + height: 120, + decoration: BoxDecoration( + color: Colors.grey[200], + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.grey[300]!), + image: _useDefaultImage + ? const DecorationImage(image: AssetImage('assets/images/set_placeholder.png'), fit: BoxFit.cover) + : (_pickedImagePath != null + ? DecorationImage(image: FileImage(File(_pickedImagePath!)), fit: BoxFit.cover) + : null), + ), + child: !_useDefaultImage && _pickedImagePath == null + ? const Icon(LucideIcons.camera, size: 40, color: Colors.grey) + : null, + ), + ), + ), + Center( + child: TextButton( + onPressed: _showImageSourceDialog, + child: const Text('画像を変更'), + ), + ), + + const SizedBox(height: 16), + + // Name + TextFormField( + controller: _nameController, + decoration: const InputDecoration( + labelText: '商品名', + hintText: '例: 3種飲み比べセット', + border: OutlineInputBorder(), + filled: true, + ), + validator: (val) => val == null || val.isEmpty ? '必須項目です' : null, + ), + const SizedBox(height: 16), + + // Price + TextFormField( + controller: _priceController, + decoration: const InputDecoration( + labelText: '価格 (税込)', + suffixText: '円', + border: OutlineInputBorder(), + filled: true, + ), + keyboardType: TextInputType.number, + validator: (val) => val == null || val.isEmpty ? '必須項目です' : null, + ), + + const SizedBox(height: 16), + + // Description + TextFormField( + controller: _descriptionController, + decoration: const InputDecoration( + labelText: '説明文', + hintText: '例: 当店おすすめの辛口3種です。', + border: OutlineInputBorder(), + filled: true, + ), + maxLines: 3, + ), + + const SizedBox(height: 24), + + // Buttons + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('キャンセル'), + ), + const SizedBox(width: 8), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.posimaiBlue, + foregroundColor: Colors.white, + elevation: 0, + ), + onPressed: _save, + child: const Text('登録'), + ), + ], + ), + ], + ), + ), + ), + ), + ); + } + + void _showImageSourceDialog() { + showModalBottomSheet( + context: context, + builder: (_) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: const Icon(LucideIcons.image), + title: const Text('プリセット画像を使用'), + onTap: () { + setState(() { + _useDefaultImage = true; + }); + Navigator.pop(context); + }, + ), + ListTile( + leading: const Icon(LucideIcons.camera), + title: const Text('カメラで撮影'), + onTap: () { + Navigator.pop(context); + _pickImage(ImageSource.camera); + }, + ), + ListTile( + leading: const Icon(LucideIcons.image), + title: const Text('アルバムから選択'), + onTap: () { + Navigator.pop(context); + _pickImage(ImageSource.gallery); + }, + ), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/analyzing_dialog.dart b/lib/widgets/analyzing_dialog.dart new file mode 100644 index 0000000..18686bc --- /dev/null +++ b/lib/widgets/analyzing_dialog.dart @@ -0,0 +1,57 @@ +import 'package:flutter/material.dart'; +import 'dart:async'; + +class AnalyzingDialog extends StatefulWidget { + const AnalyzingDialog({super.key}); + + @override + State createState() => _AnalyzingDialogState(); +} + +class _AnalyzingDialogState extends State { + int _messageIndex = 0; + + final List _messages = [ + 'ラベルを読んでいます...', + '銘柄を確認しています...', + 'この日本酒の個性を分析中...', + 'フレーバーチャートを描画しています...', + '素敵なキャッチコピーを考えています...', + ]; + + @override + void initState() { + super.initState(); + _startMessageRotation(); + } + + void _startMessageRotation() { + Future.delayed(const Duration(milliseconds: 1500), () { + if (mounted && _messageIndex < _messages.length - 1) { + setState(() => _messageIndex++); + _startMessageRotation(); + } + }); + } + + @override + Widget build(BuildContext context) { + return Dialog( + child: Padding( + padding: const EdgeInsets.all(24.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const CircularProgressIndicator(), + const SizedBox(height: 24), + Text( + _messages[_messageIndex], + style: Theme.of(context).textTheme.titleMedium, + textAlign: TextAlign.center, + ), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/home/home_empty_state.dart b/lib/widgets/home/home_empty_state.dart new file mode 100644 index 0000000..b2cdf88 --- /dev/null +++ b/lib/widgets/home/home_empty_state.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart'; +import 'package:lucide_icons/lucide_icons.dart'; + +class HomeEmptyState extends StatelessWidget { + const HomeEmptyState({super.key}); + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(LucideIcons.wine, size: 100, color: isDark ? Colors.grey[600] : Theme.of(context).primaryColor.withValues(alpha: 0.5)), + const SizedBox(height: 16), + Text( + 'まだ日本酒を登録していません', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + color: isDark ? Colors.grey[400] : Colors.grey[700], + fontWeight: FontWeight.bold + ), + ), + const SizedBox(height: 8), + Text( + 'カメラボタンから「瞬撮」してみましょう!\n長押しでギャラリーからも追加できます', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: isDark ? Colors.grey[500] : Colors.grey[600], + ), + ), + ], + ), + ); + } +} diff --git a/lib/widgets/home/sake_filter_chips.dart b/lib/widgets/home/sake_filter_chips.dart new file mode 100644 index 0000000..35693a7 --- /dev/null +++ b/lib/widgets/home/sake_filter_chips.dart @@ -0,0 +1,117 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../providers/sake_list_provider.dart'; +import '../../providers/filter_providers.dart'; +import '../../models/sake_item.dart'; +import '../../theme/app_theme.dart'; +import 'package:lucide_icons/lucide_icons.dart'; + +enum FilterChipMode { + personal, + business, +} + +class SakeFilterChips extends ConsumerWidget { + final FilterChipMode mode; + const SakeFilterChips({super.key, this.mode = FilterChipMode.personal}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + // Determine Tags based on Mode + List topTags = []; + + if (mode == FilterChipMode.business) { + topTags = ['Set', '甘口', '辛口', 'フルーティー', 'スパークリング']; + } else { + // Personal Mode: Top 15 Analysis + final rawListAsync = ref.watch(rawSakeListItemsProvider); + final Map tagFrequency = {}; + if (rawListAsync.hasValue) { + for (var item in rawListAsync.value!) { + for (var tag in item.hiddenSpecs.flavorTags) { + tagFrequency[tag] = (tagFrequency[tag] ?? 0) + 1; + } + } + } + final popularTags = tagFrequency.entries.toList()..sort((a, b) => b.value.compareTo(a.value)); + topTags = popularTags.take(15).map((e) => e.key).toList(); + } + + final selectedTag = ref.watch(sakeFilterTagProvider); + final selectedPrefecture = ref.watch(sakeFilterPrefectureProvider); + + // If selected tag is not in list (e.g. searching or custom), add it + if (selectedTag != null && selectedTag != 'All' && !topTags.contains(selectedTag)) { + topTags.insert(0, selectedTag); + } + + final colorScheme = Theme.of(context).colorScheme; + + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + children: [ + // 1. CLEAR Prefecture (High Priority) + if (selectedPrefecture != null) + Padding( + padding: const EdgeInsets.only(right: 8.0), + child: ActionChip( + avatar: const Icon(LucideIcons.x, size: 16, color: Colors.white), + label: Text(selectedPrefecture, style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold)), + backgroundColor: AppTheme.posimaiBlue, + onPressed: () => ref.read(sakeFilterPrefectureProvider.notifier).set(null), + ), + ), + + // 2. CLEAR Tag (High Priority) + if (selectedTag != null && selectedTag != 'All') + Padding( + padding: const EdgeInsets.only(right: 8.0), + child: ActionChip( + avatar: const Icon(LucideIcons.x, size: 16, color: Colors.white), + label: Text(selectedTag, style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold)), + backgroundColor: AppTheme.posimaiBlue, + onPressed: () => ref.read(sakeFilterTagProvider.notifier).set(null), + ), + ), + + // 3. "All" Button (Business Mode Explicit "All", Personal Implicit Reset) + // In Business Mode, "All" clears filters. + Padding( + padding: const EdgeInsets.only(right: 8.0), + child: FilterChip( + label: const Text('All'), + selected: selectedTag == null && selectedPrefecture == null, + selectedColor: AppTheme.posimaiBlue, + showCheckmark: false, + labelStyle: TextStyle( + color: (selectedTag == null && selectedPrefecture == null) ? Colors.white : colorScheme.onSurface, + fontWeight: FontWeight.bold, + fontSize: 13, + ), + onSelected: (_) { + ref.read(sakeFilterTagProvider.notifier).set(null); + ref.read(sakeFilterPrefectureProvider.notifier).set(null); + }, + ), + ), + + // 4. Tag List + ...topTags.where((t) => t != selectedTag).map((tag) { + return Padding( + padding: const EdgeInsets.only(right: 8.0), + child: FilterChip( + label: Text(tag, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13)), + selected: false, + onSelected: (bool selected) { + ref.read(sakeFilterTagProvider.notifier).set(selected ? tag : null); + }, + ), + ); + }), + ], + ), + ); + } +} diff --git a/lib/widgets/home/sake_grid_item.dart b/lib/widgets/home/sake_grid_item.dart new file mode 100644 index 0000000..4d6e1ce --- /dev/null +++ b/lib/widgets/home/sake_grid_item.dart @@ -0,0 +1,194 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'dart:io'; +import '../../models/sake_item.dart'; +import '../../providers/menu_providers.dart'; +import '../../screens/sake_detail_screen.dart'; +import '../../theme/app_theme.dart'; +import 'package:lucide_icons/lucide_icons.dart'; + +class SakeGridItem extends ConsumerWidget { + final SakeItem sake; + final bool isMenuMode; + + const SakeGridItem({ + super.key, + required this.sake, + required this.isMenuMode, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isSelected = ref.watch(selectedMenuSakeIdsProvider).contains(sake.id); + + return Card( + clipBehavior: Clip.antiAlias, + // Highlight selected + shape: isMenuMode && isSelected + ? RoundedRectangleBorder(side: const BorderSide(color: Colors.orange, width: 3), borderRadius: BorderRadius.circular(12)) + : null, + child: InkWell( + onTap: () { + if (isMenuMode) { + ref.read(selectedMenuSakeIdsProvider.notifier).toggle(sake.id); + return; + } + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => SakeDetailScreen(sake: sake), + ), + ); + }, + child: Stack( + fit: StackFit.expand, + children: [ + Hero( + tag: sake.id, + child: sake.displayData.imagePaths.isNotEmpty + ? Image.file( + File(sake.displayData.imagePaths.first), + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + final isDark = Theme.of(context).brightness == Brightness.dark; + return Container( + color: isDark ? Colors.grey[800] : Colors.grey[300], + child: Center( + child: Icon( + LucideIcons.imageOff, + color: isDark ? Colors.grey[600] : Colors.grey[500], + ), + ), + ); + }, + ) + : (sake.itemType == ItemType.set + ? Image.asset( + 'assets/images/set_placeholder.png', + fit: BoxFit.cover, + ) + : Container( + color: Theme.of(context).brightness == Brightness.dark + ? Colors.grey[800] + : Colors.grey[300], + child: Center( + child: Icon( + LucideIcons.image, + size: 50, + color: Theme.of(context).brightness == Brightness.dark + ? Colors.grey[600] + : Colors.grey[500], + ), + ), + )), + ), + // Gradient Overlay for Text Visibility + Positioned( + bottom: 0, + left: 0, + right: 0, + child: Container( + padding: const EdgeInsets.all(AppTheme.spacingSmall), + color: Colors.black54, // Changed from gradient to solid for "Transparent Black" underlay request + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + // セット商品バッジ + if (sake.itemType == ItemType.set) + Container( + margin: const EdgeInsets.only(bottom: 4), + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: Colors.orange, + borderRadius: BorderRadius.circular(4), + ), + child: const Text( + 'セット', + style: TextStyle( + color: Colors.white, + fontSize: 10, + fontWeight: FontWeight.bold, + ), + ), + ), + Text( + sake.displayData.name, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: 14, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + // 通常銘柄のみ酒蔵/都道府県を表示 + if (sake.itemType != ItemType.set && + (sake.displayData.brewery.isNotEmpty || sake.displayData.prefecture.isNotEmpty)) + Text( + '${sake.displayData.brewery} / ${sake.displayData.prefecture}', + style: const TextStyle( + color: Colors.white70, + fontSize: 10, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + // セット商品の説明文 + if (sake.itemType == ItemType.set && sake.displayData.catchCopy != null) + Text( + sake.displayData.catchCopy!, + style: const TextStyle( + color: Colors.white70, + fontSize: 10, + fontStyle: FontStyle.italic, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ), + // Selection Checkbox Overlay + if (isMenuMode) + Positioned( + top: AppTheme.spacingSmall, + left: AppTheme.spacingSmall, + child: Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.2), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + child: Icon( + isSelected ? Icons.check_circle : Icons.check_circle_outline, + color: isSelected ? AppTheme.posimaiBlue : Colors.grey[400], + size: 32, + ), + ), + ), + // Favorite Icon + if (sake.userData.isFavorite && !isMenuMode) + const Positioned( + top: AppTheme.spacingSmall, + right: AppTheme.spacingSmall, + child: Icon( + Icons.favorite, + color: Colors.pink, + size: 20, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/home/sake_grid_view.dart b/lib/widgets/home/sake_grid_view.dart new file mode 100644 index 0000000..695f3a8 --- /dev/null +++ b/lib/widgets/home/sake_grid_view.dart @@ -0,0 +1,77 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:reorderable_grid_view/reorderable_grid_view.dart'; +import 'package:flutter/services.dart'; // HapticFeedback + +import '../../models/sake_item.dart'; +import '../../providers/sake_list_provider.dart'; // For sakeOrderControllerProvider +import 'sake_grid_item.dart'; + +class SakeGridView extends ConsumerWidget { + final List sakeList; // Accepts List from AsyncValue but cast internal + final bool isMenuMode; + final bool enableReorder; + + const SakeGridView({ + super.key, + required this.sakeList, + required this.isMenuMode, + this.enableReorder = true, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final list = sakeList.cast(); + + // If reorder is disabled (Menu Creation Screen), use standard GridView + if (!enableReorder || isMenuMode) { + return GridView.builder( + padding: const EdgeInsets.all(4), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + crossAxisSpacing: 4, + mainAxisSpacing: 4, + childAspectRatio: 1.0, + ), + itemCount: list.length, + itemBuilder: (context, index) { + final sake = list[index]; + return SakeGridItem( + sake: sake, + isMenuMode: isMenuMode, + ); + }, + ); + } + + // Standard ReorderableGridView for Home Screen + return ReorderableGridView.builder( + padding: const EdgeInsets.all(4), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + crossAxisSpacing: 4, + mainAxisSpacing: 4, + childAspectRatio: 1.0, + ), + itemCount: list.length, + onReorder: (oldIndex, newIndex) { + final item = list.removeAt(oldIndex); + list.insert(newIndex, item); + + // Update via Provider (Normal Mode - Global Sort) + ref.read(sakeOrderControllerProvider.notifier).updateOrder(list); + HapticFeedback.lightImpact(); + }, + itemBuilder: (context, index) { + final sake = list[index]; + return KeyedSubtree( + key: ValueKey(sake.id), + child: SakeGridItem( + sake: sake, + isMenuMode: isMenuMode, + ), + ); + }, + ); + } +} diff --git a/lib/widgets/home/sake_list_item.dart b/lib/widgets/home/sake_list_item.dart new file mode 100644 index 0000000..48faedb --- /dev/null +++ b/lib/widgets/home/sake_list_item.dart @@ -0,0 +1,200 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'dart:io'; +import '../../models/sake_item.dart'; +import '../../providers/menu_providers.dart'; +import '../../screens/sake_detail_screen.dart'; +import '../../theme/app_theme.dart'; +import 'package:lucide_icons/lucide_icons.dart'; +import 'package:flutter/services.dart'; // Haptic via InkWell? No, explicit HapticFeedback used generally. + +class SakeListItem extends ConsumerWidget { + final SakeItem sake; + final bool isMenuMode; + final int? index; // For ReorderableDragStartListener + + const SakeListItem({ + super.key, + required this.sake, + required this.isMenuMode, + this.index, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isSelected = ref.watch(selectedMenuSakeIdsProvider).contains(sake.id); + final isDark = Theme.of(context).brightness == Brightness.dark; + + // Adaptive selection color + final selectedColor = isDark ? Colors.orange.withOpacity(0.3) : Colors.orange.shade50; + + return Card( + clipBehavior: Clip.antiAlias, + elevation: 1, // Slight elevation + color: isMenuMode && isSelected ? selectedColor : null, + shape: isMenuMode && isSelected + ? RoundedRectangleBorder(side: const BorderSide(color: Colors.orange, width: 2), borderRadius: BorderRadius.circular(12)) + : RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: InkWell( + onTap: () { + if (isMenuMode) { + ref.read(selectedMenuSakeIdsProvider.notifier).toggle(sake.id); + return; + } + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => SakeDetailScreen(sake: sake), + ), + ); + }, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Selection Checkbox for ListView + if (isMenuMode) + Padding( + padding: const EdgeInsets.symmetric(vertical: AppTheme.spacingXLarge, horizontal: AppTheme.spacingMedium), + child: Icon( + isSelected ? Icons.check_circle : Icons.check_circle_outline, + color: isSelected ? AppTheme.posimaiBlue : Colors.grey[300], + size: 28, + ), + ), + + SizedBox( + width: 100, + height: 100, + child: Hero( + tag: sake.id, + child: sake.displayData.imagePaths.isNotEmpty + ? Image.file( + File(sake.displayData.imagePaths.first), + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return Container( + color: isDark ? Colors.grey[800] : Colors.grey[300], + child: Center( + child: Icon( + LucideIcons.imageOff, + color: isDark ? Colors.grey[600] : Colors.grey[500], + ), + ), + ); + }, + ) + : (sake.itemType == ItemType.set + ? Image.asset( + 'assets/images/set_placeholder.png', + fit: BoxFit.cover, + ) + : Container( + color: isDark ? Colors.grey[800] : Colors.grey[300], + child: Center( + child: Icon( + LucideIcons.image, + size: 40, + color: isDark ? Colors.grey[600] : Colors.grey[500], + ), + ), + )), + ), + ), + Expanded( + child: Padding( + padding: const EdgeInsets.all(AppTheme.spacingMedium), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Row( + children: [ + // セット商品バッジ + if (sake.itemType == ItemType.set) + Container( + margin: const EdgeInsets.only(right: 6), + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: Colors.orange, + borderRadius: BorderRadius.circular(4), + ), + child: const Text( + 'セット', + style: TextStyle( + color: Colors.white, + fontSize: 10, + fontWeight: FontWeight.bold, + ), + ), + ), + Expanded( + child: Text( + sake.displayData.name, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + color: (isMenuMode && isSelected && !isDark) ? Colors.brown[900] : null, + ), + ), + ), + ], + ), + ), + if (sake.userData.isFavorite && !isMenuMode) + const Icon(Icons.favorite, color: Colors.pink, size: 16), + ], + ), + const SizedBox(height: AppTheme.spacingTiny), + // Brand / Prefecture (セット商品では非表示) + if (sake.itemType != ItemType.set && + (sake.displayData.brewery.isNotEmpty || sake.displayData.prefecture.isNotEmpty)) + Row( + children: [ + Expanded( + child: Text( + '${sake.displayData.brewery} / ${sake.displayData.prefecture}', + style: Theme.of(context).textTheme.bodySmall, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + // セット商品の説明文表示 + if (sake.itemType == ItemType.set && sake.displayData.catchCopy != null) + Text( + sake.displayData.catchCopy!, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + fontStyle: FontStyle.italic, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + if (!isMenuMode && sake.hiddenSpecs.flavorTags.isNotEmpty) ...[ + const SizedBox(height: AppTheme.spacingSmall), + Wrap( + spacing: 4, + runSpacing: 4, + children: sake.hiddenSpecs.flavorTags.take(3).map((tag) => Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: isDark ? Colors.grey[800] : Colors.grey[200], + borderRadius: BorderRadius.circular(4), + ), + child: Text( + tag, + style: TextStyle(fontSize: 10, color: isDark ? Colors.grey[300] : Colors.grey[800]), + ), + )).toList(), + ) + ] + ], + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/home/sake_list_view.dart b/lib/widgets/home/sake_list_view.dart new file mode 100644 index 0000000..1dbbca1 --- /dev/null +++ b/lib/widgets/home/sake_list_view.dart @@ -0,0 +1,82 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter/services.dart'; // HapticFeedback + +import '../../models/sake_item.dart'; +import '../../providers/sake_list_provider.dart'; +import 'sake_list_item.dart'; + +class SakeListView extends ConsumerWidget { + final List sakeList; + final bool isMenuMode; + final bool enableReorder; + + const SakeListView({ + super.key, + required this.sakeList, + required this.isMenuMode, + this.enableReorder = false, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + // Dynamic cast for Hive list + final list = sakeList.cast(); + + // If reorder is disabled or in Menu Mode, use standard ListView + if (!enableReorder || isMenuMode) { + return ListView.builder( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12), + itemCount: list.length, + itemBuilder: (context, index) { + return Padding( + padding: const EdgeInsets.only(bottom: 4), + child: SakeListItem( + sake: list[index], + isMenuMode: isMenuMode, + index: null, // No drag handle + ), + ); + } + ); + } + + // Standard ReorderableListView for Home Screen + return ReorderableListView.builder( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12), + itemCount: list.length, + onReorder: (oldIndex, newIndex) { + if (oldIndex < newIndex) { + newIndex -= 1; + } + // Perform Reorder locally + final item = list.removeAt(oldIndex); + list.insert(newIndex, item); + // Update Order via Controller (Normal Mode - Global Sort) + ref.read(sakeOrderControllerProvider.notifier).updateOrder(list); + + // Haptic + HapticFeedback.lightImpact(); + }, + proxyDecorator: (widget, index, animation) { + return Material( + elevation: 4, + color: Colors.transparent, + child: widget, + ); + }, + itemBuilder: (context, index) { + final sake = list[index]; + return Padding( + key: ValueKey(sake.id), + padding: const EdgeInsets.only(bottom: 4), + child: SakeListItem( + sake: sake, + isMenuMode: isMenuMode, + index: index // For drag handle + ), + ); + }, + ); + } +} diff --git a/lib/widgets/home/sake_no_match_state.dart b/lib/widgets/home/sake_no_match_state.dart new file mode 100644 index 0000000..6a0b128 --- /dev/null +++ b/lib/widgets/home/sake_no_match_state.dart @@ -0,0 +1,31 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../providers/filter_providers.dart'; +import 'package:lucide_icons/lucide_icons.dart'; + +class SakeNoMatchState extends ConsumerWidget { + const SakeNoMatchState({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(LucideIcons.filterX, size: 48, color: Colors.grey[400]), + const SizedBox(height: 16), + Text('条件に一致するお酒が見つかりません', style: TextStyle(color: Colors.grey[600])), + TextButton( + child: const Text('フィルタを解除'), + onPressed: () { + ref.read(sakeSearchQueryProvider.notifier).set(''); + ref.read(sakeFilterFavoriteProvider.notifier).set(false); + ref.read(sakeFilterTagProvider.notifier).set(null); + ref.read(sakeFilterPrefectureProvider.notifier).set(null); + }, + ) + ], + ), + ); + } +} diff --git a/lib/widgets/map/pixel_japan_map.dart b/lib/widgets/map/pixel_japan_map.dart new file mode 100644 index 0000000..4dfd469 --- /dev/null +++ b/lib/widgets/map/pixel_japan_map.dart @@ -0,0 +1,87 @@ +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/onboarding_dialog.dart b/lib/widgets/onboarding_dialog.dart new file mode 100644 index 0000000..5314312 --- /dev/null +++ b/lib/widgets/onboarding_dialog.dart @@ -0,0 +1,166 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +class OnboardingDialog extends StatefulWidget { + final VoidCallback onFinish; + final List>? pages; // Optional content + + const OnboardingDialog({super.key, required this.onFinish, this.pages}); + + @override + State createState() => _OnboardingDialogState(); +} + +class _OnboardingDialogState extends State { + final PageController _controller = PageController(); + int _currentPage = 0; + + late final List> _pages; + + @override + void initState() { + super.initState(); + _pages = widget.pages ?? [ + { + 'title': '日本酒の『魂』を保存する', + 'description': 'Ponshu Roomへようこそ。\n飲んだ日本酒のラベルを撮影するだけで、\nAIが情報を解析し、あなたのリストに加えます。', + 'icon': '🍶', + }, + { + 'title': 'ラベルを撮るだけ', + 'description': '面倒な入力は不要です。\nラベルの写真を撮れば、銘柄、蔵元、味わいまで\nAIが自動でデータベース化します。', + 'icon': '📸', + }, + { + 'title': 'あなただけのお品書き', + 'description': '集めたコレクションから、\nボタンひとつで美しいお品書きPDFを作成。\n飲食店の方も、個人の方も楽しめます。', + 'icon': '📜', + }, + { + 'title': 'さあ、始めましょう', + 'description': 'AIの力で、日本酒ライフをもっと豊かに。\n右下のカメラボタンからスタートしてください。', + 'icon': '✨', + }, + ]; + } + + @override + Widget build(BuildContext context) { + return Dialog( + backgroundColor: Colors.transparent, // Custom shape + insetPadding: const EdgeInsets.all(20), + child: Container( + height: 500, + decoration: BoxDecoration( + color: Theme.of(context).scaffoldBackgroundColor, + borderRadius: BorderRadius.circular(24), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.3), + blurRadius: 20, + offset: const Offset(0, 10), + ) + ], + ), + child: Column( + children: [ + Expanded( + child: PageView.builder( + controller: _controller, + onPageChanged: (index) => setState(() => _currentPage = index), + itemCount: _pages.length, + itemBuilder: (context, index) { + final page = _pages[index]; + return Center( + child: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.all(32.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + page['icon']!, + style: const TextStyle(fontSize: 80), + ), + const SizedBox(height: 32), + Text( + page['title']!, + textAlign: TextAlign.center, + style: GoogleFonts.zenOldMincho( + fontSize: 24, + fontWeight: FontWeight.bold, + color: Theme.of(context).textTheme.bodyLarge?.color, + ), + ), + const SizedBox(height: 16), + Text( + page['description']!, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + height: 1.6, + color: Colors.grey, + ), + ), + ], + ), + ), + ), + ); + }, + ), + ), + // Indicators + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: List.generate(_pages.length, (index) { + return AnimatedContainer( + duration: const Duration(milliseconds: 300), + margin: const EdgeInsets.symmetric(horizontal: 4), + height: 8, + width: _currentPage == index ? 24 : 8, + decoration: BoxDecoration( + color: _currentPage == index + ? Theme.of(context).primaryColor + : Colors.grey[300], + borderRadius: BorderRadius.circular(4), + ), + ); + }), + ), + const SizedBox(height: 24), + // Button + Padding( + padding: const EdgeInsets.symmetric(horizontal: 32.0, vertical: 24.0), + child: SizedBox( + width: double.infinity, + height: 50, + child: ElevatedButton( + style: ElevatedButton.styleFrom( + elevation: 0, + backgroundColor: Theme.of(context).primaryColor, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + onPressed: () { + if (_currentPage < _pages.length - 1) { + _controller.nextPage( + duration: const Duration(milliseconds: 300), + curve: Curves.easeIn, + ); + } else { + widget.onFinish(); + } + }, + child: Text( + _currentPage < _pages.length - 1 ? '次へ' : 'はじめる', + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16), + ), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/prefecture_filter_sheet.dart b/lib/widgets/prefecture_filter_sheet.dart new file mode 100644 index 0000000..97b6767 --- /dev/null +++ b/lib/widgets/prefecture_filter_sheet.dart @@ -0,0 +1,121 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../theme/app_theme.dart'; +import '../providers/sake_list_provider.dart'; +import '../providers/filter_providers.dart'; + +class PrefectureFilterSheet extends ConsumerWidget { + const PrefectureFilterSheet({super.key}); + + static Future show(BuildContext context) { + return showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + builder: (context) => const PrefectureFilterSheet(), + ); + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final rawListAsync = ref.read(rawSakeListItemsProvider); + final selectedPref = ref.read(sakeFilterPrefectureProvider); + + // 1. JIS X 0401 Prefectures (North to South) + const distinctPrefectures = [ + '北海道', '青森県', '岩手県', '宮城県', '秋田県', '山形県', '福島県', + '茨城県', '栃木県', '群馬県', '埼玉県', '千葉県', '東京都', '神奈川県', + '新潟県', '富山県', '石川県', '福井県', '山梨県', '長野県', '岐阜県', '静岡県', '愛知県', + '三重県', '滋賀県', '京都府', '大阪府', '兵庫県', '奈良県', '和歌山県', + '鳥取県', '島根県', '岡山県', '広島県', '山口県', + '徳島県', '香川県', '愛媛県', '高知県', + '福岡県', '佐賀県', '長崎県', '熊本県', '大分県', '宮崎県', '鹿児島県', '沖縄県' + ]; + + // 2. Identify available prefectures from data + final availablePrefs = {}; + if (rawListAsync.hasValue) { + for (var item in rawListAsync.value!) { + if (item.displayData.prefecture != '不明') { + availablePrefs.add(item.displayData.prefecture); + } + } + } + + // 3. Build UI List (Filter standard list by 'contains in availablePrefs') + final sortedAvailablePrefs = distinctPrefectures + .where((pref) => availablePrefs.contains(pref)) + .toList(); + + // Add "All" option at the top + final pickerItems = ['全国', ...sortedAvailablePrefs]; + + // Initial Index + int scrollIndex = 0; + if (selectedPref != null) { + final idx = pickerItems.indexOf(selectedPref); + if (idx != -1) scrollIndex = idx; + } + + return Container( + height: 300, + decoration: BoxDecoration( + color: Theme.of(context).scaffoldBackgroundColor, + borderRadius: const BorderRadius.vertical(top: Radius.circular(16)), + ), + child: Column( + children: [ + // Toolbar + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + border: Border(bottom: BorderSide(color: Colors.grey.withOpacity(0.2))), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('キャンセル', style: TextStyle(color: Colors.grey)), + ), + const Text('都道府県を選択', style: TextStyle(fontWeight: FontWeight.bold)), + TextButton( + onPressed: () { + Navigator.pop(context); + }, + child: const Text('決定', style: TextStyle(fontWeight: FontWeight.bold)), + ), + ], + ), + ), + // Picker + Expanded( + child: CupertinoPicker( + itemExtent: 40, + scrollController: FixedExtentScrollController(initialItem: scrollIndex), + onSelectedItemChanged: (index) { + final value = pickerItems[index]; + // Haptic Feedback for "Rolling" feel + HapticFeedback.selectionClick(); + + ref.read(sakeFilterPrefectureProvider.notifier).set( + value == '全国' ? null : value + ); + }, + children: pickerItems.map((pref) => Center( + child: Text( + pref, + style: TextStyle( + color: Theme.of(context).textTheme.bodyLarge?.color, + fontSize: 18, + ), + ), + )).toList(), + ), + ), + ], + ), + ); + } +} diff --git a/lib/widgets/quota_warning_dialog.dart b/lib/widgets/quota_warning_dialog.dart new file mode 100644 index 0000000..478f350 --- /dev/null +++ b/lib/widgets/quota_warning_dialog.dart @@ -0,0 +1,114 @@ +import 'package:flutter/material.dart'; +import 'package:lucide_icons/lucide_icons.dart'; + +/// Gemini API使用制限の警告ダイアログ +class QuotaWarningDialog extends StatelessWidget { + const QuotaWarningDialog({super.key}); + + @override + Widget build(BuildContext context) { + return AlertDialog( + icon: const Icon(LucideIcons.alertTriangle, color: Colors.orange, size: 48), + title: const Text( + 'AI使用制限について', + textAlign: TextAlign.center, + style: TextStyle(fontWeight: FontWeight.bold), + ), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Gemini AI無料版の制限:', + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14), + ), + const SizedBox(height: 8), + _buildLimitItem('最大15回/分', 'RPM (Requests Per Minute)'), + _buildLimitItem('最大1,500回/日', 'RPD (Requests Per Day)'), + _buildLimitItem('最大100万トークン/分', 'TPM (画像1枚≒数万トークン)'), + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.orange.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.orange.withValues(alpha: 0.3)), + ), + child: const Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(LucideIcons.lightbulb, size: 16, color: Colors.orange), + SizedBox(width: 8), + Text( + '推奨事項', + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13), + ), + ], + ), + SizedBox(height: 8), + Text( + '• 解析は5秒以上間隔を空けてください\n' + '• 同じ画像を複数回解析しないでください\n' + '• エラーが出たら1〜2分待ってください', + style: TextStyle(fontSize: 12, height: 1.5), + ), + ], + ), + ), + const SizedBox(height: 16), + const Text( + '※ 新しいAPIキーでも同じIPアドレスから利用する場合、制限が共有される可能性があります。', + style: TextStyle(fontSize: 11, color: Colors.grey, height: 1.4), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('閉じる'), + ), + ], + ); + } + + Widget _buildLimitItem(String value, String description) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4.0), + child: Row( + children: [ + const Icon(LucideIcons.checkCircle2, size: 16, color: Colors.green), + const SizedBox(width: 8), + Expanded( + child: RichText( + text: TextSpan( + style: const TextStyle(fontSize: 12, color: Colors.black87), + children: [ + TextSpan( + text: value, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + TextSpan( + text: ' - $description', + style: const TextStyle(color: Colors.grey), + ), + ], + ), + ), + ), + ], + ), + ); + } +} + +/// API制限警告を表示する便利メソッド +void showQuotaWarning(BuildContext context) { + showDialog( + context: context, + builder: (context) => const QuotaWarningDialog(), + ); +} diff --git a/lib/widgets/sake_3d_carousel.dart b/lib/widgets/sake_3d_carousel.dart new file mode 100644 index 0000000..f778088 --- /dev/null +++ b/lib/widgets/sake_3d_carousel.dart @@ -0,0 +1,205 @@ +import 'dart:io'; +import 'dart:math'; +import 'package:flutter/material.dart'; +import '../models/sake_item.dart'; +import '../screens/sake_detail_screen.dart'; + +/// 3D風カルーセルウィジェット +/// 奥行き感のあるくるくるスクロールで日本酒を選択 +class Sake3DCarousel extends StatefulWidget { + final List items; + final double height; + + const Sake3DCarousel({ + super.key, + required this.items, + this.height = 200, + }); + + @override + State createState() => _Sake3DCarouselState(); +} + +class _Sake3DCarouselState extends State { + late PageController _pageController; + double _currentPage = 0; + + @override + void initState() { + super.initState(); + _pageController = PageController( + viewportFraction: 0.35, // 隣のカードも見える + initialPage: 0, + ); + _pageController.addListener(() { + setState(() { + _currentPage = _pageController.page ?? 0; + }); + }); + } + + @override + void dispose() { + _pageController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (widget.items.isEmpty) { + return SizedBox( + height: widget.height, + child: const Center( + child: Text('関連する日本酒がありません'), + ), + ); + } + + return SizedBox( + height: widget.height, + child: PageView.builder( + controller: _pageController, + itemCount: widget.items.length, + itemBuilder: (context, index) { + return _buildCarouselItem(widget.items[index], index); + }, + ), + ); + } + + Widget _buildCarouselItem(SakeItem item, int index) { + // 現在のページからの距離を計算 + final diff = (_currentPage - index).abs(); + + // 3D効果のための変換パラメータ + final scale = max(0.8, 1 - (diff * 0.2)); // スケール: 0.8〜1.0 + final opacity = max(0.4, 1 - (diff * 0.3)); // 透明度: 0.4〜1.0 + final rotation = (diff * 0.1).clamp(-0.2, 0.2); // 回転: -0.2〜0.2 rad + + return Transform( + transform: Matrix4.identity() + ..setEntry(3, 2, 0.001) // 透視効果 + ..scale(scale) + ..rotateY(rotation), + alignment: Alignment.center, + child: Opacity( + opacity: opacity, + child: GestureDetector( + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => SakeDetailScreen(sake: item), + ), + ); + }, + child: Card( + elevation: 8, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + clipBehavior: Clip.antiAlias, + child: Stack( + fit: StackFit.expand, + children: [ + // 背景画像 + item.displayData.imagePaths.isNotEmpty + ? Image.file( + File(item.displayData.imagePaths.first), + fit: BoxFit.cover, + ) + : Container( + color: Colors.grey[300], + child: const Icon(Icons.image, size: 50), + ), + + // グラデーションオーバーレイ + Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.transparent, + Colors.black.withValues(alpha: 0.7), + ], + ), + ), + ), + + // テキスト情報 + Positioned( + bottom: 16, + left: 12, + right: 12, + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + item.displayData.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: const TextStyle( + color: Colors.white, + fontSize: 14, + fontWeight: FontWeight.bold, + shadows: [ + Shadow( + color: Colors.black, + blurRadius: 4, + ), + ], + ), + ), + const SizedBox(height: 4), + Text( + item.displayData.prefecture, + style: TextStyle( + color: Colors.white.withValues(alpha: 0.9), + fontSize: 11, + shadows: const [ + Shadow( + color: Colors.black, + blurRadius: 4, + ), + ], + ), + ), + ], + ), + ), + + // 中央のカードにインジケーター + if (diff < 0.5) + Positioned( + top: 8, + right: 8, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: BoxDecoration( + color: Theme.of(context).primaryColor, + borderRadius: BorderRadius.circular(12), + ), + child: const Text( + 'おすすめ', + style: TextStyle( + color: Colors.white, + fontSize: 10, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/widgets/sake_price_dialog.dart b/lib/widgets/sake_price_dialog.dart new file mode 100644 index 0000000..a68e252 --- /dev/null +++ b/lib/widgets/sake_price_dialog.dart @@ -0,0 +1,359 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import '../models/sake_item.dart'; +import '../services/pricing_helper.dart'; +import '../theme/app_theme.dart'; + +/// 価格設定ダイアログ +/// +/// 「一合の価格入力」→「提供サイズ選択」→「お品書き掲載価格」の +/// シンプルなフローで価格を設定します。 +/// +/// 使用例: +/// ```dart +/// showDialog( +/// context: context, +/// builder: (context) => SakePriceDialog( +/// sakeItem: item, +/// onSave: (basePrice, variants) { +/// // 保存処理 +/// }, +/// ), +/// ); +/// ``` +class SakePriceDialog extends StatefulWidget { + final SakeItem sakeItem; + final Function(int basePrice, Map variants) onSave; + + const SakePriceDialog({ + super.key, + required this.sakeItem, + required this.onSave, + }); + + @override + State createState() => _SakePriceDialogState(); +} + +class _SakePriceDialogState extends State { + final TextEditingController _basePriceController = TextEditingController(); + final FocusNode _basePriceFocus = FocusNode(); + + int? _basePrice; // 一合価格 + Map _variants = {}; // 選択されたサイズと価格 + + @override + void initState() { + super.initState(); + + // 既存の価格データを読み込む + if (widget.sakeItem.userData.price != null) { + _basePrice = widget.sakeItem.userData.price!; + _basePriceController.text = PricingHelper.formatPrice(_basePrice!); + } + + // 既存のバリエーションを読み込む + if (widget.sakeItem.userData.priceVariants != null) { + _variants = Map.from(widget.sakeItem.userData.priceVariants!); + } + + // フォーカスアウト時にカンマ区切りをフォーマット + _basePriceFocus.addListener(() { + if (!_basePriceFocus.hasFocus && _basePrice != null) { + setState(() { + _basePriceController.text = PricingHelper.formatPrice(_basePrice!); + }); + } + }); + } + + @override + void dispose() { + _basePriceController.dispose(); + _basePriceFocus.dispose(); + super.dispose(); + } + + /// サイズを追加 + void _addSize(String size) { + if (_basePrice == null || _basePrice! <= 0) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('先に一合の価格を入力してください')), + ); + return; + } + + setState(() { + final calculatedPrice = PricingHelper.calculateSizePrice(_basePrice!, size); + _variants[size] = calculatedPrice; + }); + + // ハプティックフィードバック + HapticFeedback.lightImpact(); + } + + /// サイズを削除 + void _removeSize(String size) { + setState(() { + _variants.remove(size); + }); + HapticFeedback.lightImpact(); + } + + /// 価格を保存 + void _save() { + if (_basePrice == null || _basePrice! <= 0) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('一合の価格を入力してください')), + ); + return; + } + + widget.onSave(_basePrice!, _variants); + Navigator.of(context).pop(); + } + + @override + Widget build(BuildContext context) { + final screenHeight = MediaQuery.of(context).size.height; + + return Dialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + child: Container( + constraints: BoxConstraints( + maxWidth: 500, + maxHeight: screenHeight * 0.85, // 画面の85%まで + ), + child: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.all(20), // 24 → 20に縮小 + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // ヘッダー: 銘柄名 + Text( + widget.sakeItem.displayData.name, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), // 8 → 4 + Text( + '${widget.sakeItem.displayData.brewery} / ${widget.sakeItem.displayData.prefecture}', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Colors.grey[600], + ), + ), + const SizedBox(height: 16), // 24 → 16 + + // セクション1: 一合の税込価格 + Text( + '一合の税込価格', + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + TextField( + controller: _basePriceController, + focusNode: _basePriceFocus, + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + decoration: InputDecoration( + hintText: '例: 1800', + suffixText: '円', + border: const OutlineInputBorder(), + filled: true, + fillColor: _basePrice != null && _basePrice! > 0 + ? Colors.green.withValues(alpha: 0.05) + : null, + ), + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + onChanged: (value) { + setState(() { + _basePrice = PricingHelper.parsePrice(value); + }); + }, + ), + const SizedBox(height: 16), // 24 → 16 + + // セクション2: 提供サイズ選択 + Text( + '提供サイズ選択', + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: PricingHelper.availableSizes.map((size) { + final isSelected = _variants.containsKey(size); + final isDark = Theme.of(context).brightness == Brightness.dark; + return FilterChip( + label: Text(size), + selected: isSelected, + onSelected: (selected) { + if (selected) { + _addSize(size); + } else { + _removeSize(size); + } + }, + selectedColor: AppTheme.posimaiBlue.withValues(alpha: isDark ? 0.4 : 0.2), + checkmarkColor: isDark ? Colors.white : AppTheme.posimaiBlue, + backgroundColor: isDark ? Colors.grey[800] : null, + labelStyle: TextStyle( + color: isSelected + ? (isDark ? Colors.white : AppTheme.posimaiBlue) + : (isDark ? Colors.grey[300] : null), + fontWeight: isSelected ? FontWeight.bold : null, + ), + ); + }).toList(), + ), + const SizedBox(height: 16), // 24 → 16 + + // セクション3: お品書き掲載価格 + if (_variants.isNotEmpty) ...[ + Text( + 'お品書き掲載価格', + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + Container( + decoration: BoxDecoration( + border: Border.all(color: Colors.grey[300]!), + borderRadius: BorderRadius.circular(8), + ), + child: Column( + children: _variants.entries.map((entry) { + return _buildPriceItem(entry.key, entry.value); + }).toList(), + ), + ), + const SizedBox(height: 16), // 24 → 16 + ], + + // ボタン + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('キャンセル'), + ), + const SizedBox(width: 8), + ElevatedButton( + onPressed: _save, + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.posimaiBlue, + foregroundColor: Colors.white, + ), + child: const Text('保存'), + ), + ], + ), + ], + ), + ), + ), + ), + ); + } + + /// 価格アイテム (インライン編集可能) + Widget _buildPriceItem(String size, int price) { + return InkWell( + onTap: () => _showPriceEditDialog(size, price), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), // 12 → 10 + decoration: BoxDecoration( + border: Border( + bottom: BorderSide(color: Colors.grey[200]!), + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + size, + style: const TextStyle(fontSize: 15), // 16 → 15 + ), + Row( + children: [ + Text( + '${PricingHelper.formatPrice(price)}円', + style: const TextStyle( + fontSize: 16, // 18 → 16 + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(width: 4), // 8 → 4 + IconButton( + icon: const Icon(Icons.close, size: 18), // 20 → 18 + onPressed: () => _removeSize(size), + tooltip: '削除', + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ), + ], + ), + ], + ), + ), + ); + } + + /// 価格編集ダイアログ (インライン編集) + void _showPriceEditDialog(String size, int currentPrice) { + final controller = TextEditingController( + text: PricingHelper.formatPrice(currentPrice), + ); + + showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text('$size の価格を変更'), + content: TextField( + controller: controller, + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + decoration: const InputDecoration( + hintText: '価格を入力', + suffixText: '円', + border: OutlineInputBorder(), + ), + autofocus: true, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('キャンセル'), + ), + ElevatedButton( + onPressed: () { + final newPrice = PricingHelper.parsePrice(controller.text); + if (newPrice != null && newPrice > 0) { + setState(() { + _variants[size] = newPrice; + }); + Navigator.pop(context); + } + }, + child: const Text('変更'), + ), + ], + ), + ); + } +} diff --git a/lib/widgets/sake_radar_chart.dart b/lib/widgets/sake_radar_chart.dart new file mode 100644 index 0000000..b498d50 --- /dev/null +++ b/lib/widgets/sake_radar_chart.dart @@ -0,0 +1,97 @@ + +import 'package:flutter/material.dart'; +import 'package:fl_chart/fl_chart.dart'; + +class SakeRadarChart extends StatelessWidget { + final Map tasteStats; + final Color primaryColor; + + const SakeRadarChart({ + super.key, + required this.tasteStats, + required this.primaryColor, + }); + + @override + Widget build(BuildContext context) { + // Default values if stats are missing + final aroma = tasteStats['aroma']?.toDouble() ?? 3.0; + final sweetness = tasteStats['sweetness']?.toDouble() ?? 3.0; + final acidity = tasteStats['acidity']?.toDouble() ?? 3.0; + final bitterness = tasteStats['bitterness']?.toDouble() ?? 3.0; + final body = tasteStats['body']?.toDouble() ?? 3.0; + + return AspectRatio( + aspectRatio: 1.3, + child: RadarChart( + RadarChartData( + radarTouchData: RadarTouchData(enabled: false), + dataSets: [ + RadarDataSet( + fillColor: primaryColor.withValues(alpha: 0.2), + borderColor: Theme.of(context).brightness == Brightness.dark + ? Colors.white.withValues(alpha: 0.8) // High contrast white + : primaryColor.withValues(alpha: 0.7), + entryRadius: 3, + dataEntries: [ + RadarEntry(value: aroma), + RadarEntry(value: sweetness), + RadarEntry(value: acidity), + RadarEntry(value: bitterness), + RadarEntry(value: body), + ], + borderWidth: 3, + ), + ], + radarBackgroundColor: Colors.transparent, + borderData: FlBorderData(show: false), + radarBorderData: const BorderSide(color: Colors.transparent), + titlePositionPercentageOffset: 0.2, + titleTextStyle: TextStyle( + color: Theme.of(context).brightness == Brightness.dark + ? Colors.orange[100] // Light orange for labels + : primaryColor, + fontSize: 10, + fontWeight: FontWeight.bold + ), + getTitle: (index, angle) { + String label; + switch (index) { + case 0: + label = '香り'; + break; + case 1: + label = '甘み'; + break; + case 2: + label = '酸味'; + break; + case 3: + label = 'キレ'; + break; + case 4: + label = 'コク'; + break; + default: + return const RadarChartTitle(text: ''); + } + + return RadarChartTitle( + text: label, + angle: angle, + ); + }, + tickCount: 4, + ticksTextStyle: const TextStyle(color: Colors.transparent, fontSize: 0), + tickBorderData: const BorderSide(color: Colors.transparent), + gridBorderData: BorderSide( + color: Theme.of(context).brightness == Brightness.dark + ? Colors.white.withValues(alpha: 0.3) // Brighter grid + : primaryColor.withValues(alpha: 0.2), + width: 1 + ), + ), + ), + ); + } +} diff --git a/lib/widgets/sake_search_delegate.dart b/lib/widgets/sake_search_delegate.dart new file mode 100644 index 0000000..78ecf49 --- /dev/null +++ b/lib/widgets/sake_search_delegate.dart @@ -0,0 +1,93 @@ + +import 'package:flutter/material.dart'; +import 'package:lucide_icons/lucide_icons.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../providers/sake_list_provider.dart'; +import '../screens/sake_detail_screen.dart'; +import 'dart:io'; + +class SakeSearchDelegate extends SearchDelegate { + final WidgetRef ref; + + SakeSearchDelegate(this.ref); + + @override + List? buildActions(BuildContext context) { + return [ + IconButton( + icon: const Icon(LucideIcons.x), + onPressed: () { + query = ''; + }, + ), + ]; + } + + @override + Widget? buildLeading(BuildContext context) { + return IconButton( + icon: const Icon(LucideIcons.arrowLeft), + onPressed: () { + close(context, null); + }, + ); + } + + @override + Widget buildResults(BuildContext context) { + return _buildList(context); + } + + @override + Widget buildSuggestions(BuildContext context) { + return _buildList(context); + } + + Widget _buildList(BuildContext context) { + // Access the RAW provider to search across all items, ignoring current filters + final rawListAsync = ref.read(rawSakeListItemsProvider); + + return rawListAsync.when( + data: (list) { + final filtered = list.where((item) { + final q = query.toLowerCase(); + return item.displayData.name.toLowerCase().contains(q) || + item.displayData.brewery.toLowerCase().contains(q) || + item.displayData.prefecture.toLowerCase().contains(q); + }).toList(); + + if (filtered.isEmpty) { + return const Center(child: Text('No results found')); + } + + return ListView.builder( + itemCount: filtered.length, + itemBuilder: (context, index) { + final sake = filtered[index]; + return ListTile( + leading: SizedBox( + width: 40, + height: 40, + child: sake.displayData.imagePaths.isNotEmpty + ? Image.file(File(sake.displayData.imagePaths.first), fit: BoxFit.cover) + : const Icon(LucideIcons.image), + ), + title: Text(sake.displayData.name), + subtitle: Text('${sake.displayData.brewery} / ${sake.displayData.prefecture}'), + onTap: () { + close(context, null); + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => SakeDetailScreen(sake: sake), + ), + ); + }, + ); + }, + ); + }, + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, s) => Center(child: Text('Error: $e')), + ); + } +} diff --git a/lib/widgets/settings/app_settings_section.dart b/lib/widgets/settings/app_settings_section.dart new file mode 100644 index 0000000..5226e5b --- /dev/null +++ b/lib/widgets/settings/app_settings_section.dart @@ -0,0 +1,109 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:lucide_icons/lucide_icons.dart'; +import '../../providers/theme_provider.dart'; + +class AppearanceSettingsSection extends ConsumerWidget { + const AppearanceSettingsSection({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final userProfile = ref.watch(userProfileProvider); + final themeMode = userProfile.themeMode; + final fontPref = userProfile.fontPreference; + final isDark = Theme.of(context).brightness == Brightness.dark; + + return Column( + children: [ + _buildSectionHeader(context, 'アプリ設定', LucideIcons.palette), + Card( + child: Column( + children: [ + ListTile( + leading: Icon(LucideIcons.type, color: isDark ? Colors.grey[400] : null), + title: const Text('フォント'), + subtitle: Text(fontPref == 'serif' ? '明朝 (Serif)' : 'ゴシック (Sans)'), + trailing: Switch( + value: fontPref == 'serif', + onChanged: (val) { + ref.read(userProfileProvider.notifier) + .setFontPreference(val ? 'serif' : 'sans'); + }, + ), + ), + const Divider(height: 1), + ListTile( + leading: Icon(LucideIcons.sunMoon, color: isDark ? Colors.grey[400] : null), + title: const Text('テーマ設定'), + subtitle: Text(_getThemeModeName(themeMode)), + trailing: Icon(LucideIcons.chevronRight, color: isDark ? Colors.grey[600] : null), + onTap: () => _showThemeDialog(context, ref, themeMode), + ), + ], + ), + ), + ], + ); + } + + Widget _buildSectionHeader(BuildContext context, String title, IconData icon) { + final isDark = Theme.of(context).brightness == Brightness.dark; + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 4), + child: Row( + children: [ + Icon(icon, size: 20, color: isDark ? Colors.orange[300] : Theme.of(context).primaryColor), + const SizedBox(width: 8), + Text( + title, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + color: isDark ? Colors.grey[300] : Theme.of(context).primaryColor, + ), + ), + ], + ), + ); + } + + String _getThemeModeName(String mode) { + switch (mode) { + case 'light': return 'ライト'; + case 'dark': return 'ダーク'; + default: return 'システム設定'; + } + } + + void _showThemeDialog(BuildContext context, WidgetRef ref, String current) { + showDialog( + context: context, + builder: (context) => SimpleDialog( + title: const Text('テーマ設定'), + children: [ + _buildThemeOption(context, ref, 'system', 'システム設定', current), + _buildThemeOption(context, ref, 'light', 'ライトモード', current), + _buildThemeOption(context, ref, 'dark', 'ダークモード', current), + ], + ), + ); + } + + Widget _buildThemeOption(BuildContext context, WidgetRef ref, String value, String label, String current) { + return SimpleDialogOption( + onPressed: () { + ref.read(userProfileProvider.notifier).setThemeMode(value); + Navigator.pop(context); + }, + child: Row( + children: [ + Icon( + value == current ? Icons.radio_button_checked : Icons.radio_button_unchecked, + color: value == current ? Theme.of(context).primaryColor : Colors.grey, + ), + const SizedBox(width: 12), + Text(label), + ], + ), + ); + } +} diff --git a/lib/widgets/settings/backup_settings_section.dart b/lib/widgets/settings/backup_settings_section.dart new file mode 100644 index 0000000..38e269a --- /dev/null +++ b/lib/widgets/settings/backup_settings_section.dart @@ -0,0 +1,231 @@ +import 'package:flutter/material.dart'; +import 'package:lucide_icons/lucide_icons.dart'; +import '../../services/backup_service.dart'; + +class BackupSettingsSection extends StatefulWidget { + final String title; + + const BackupSettingsSection({ + super.key, + this.title = 'データバックアップ', + }); + + @override + State createState() => _BackupSettingsSectionState(); +} + +enum _BackupState { idle, signingIn, signingOut, backingUp, restoring } + + class _BackupSettingsSectionState extends State { + final BackupService _backupService = BackupService(); + _BackupState _state = _BackupState.idle; + + @override + void initState() { + super.initState(); + _initBackupService(); + } + + Future _initBackupService() async { + await _backupService.init(); + if (mounted) { + setState(() {}); + } + } + + Future _signIn() async { + setState(() => _state = _BackupState.signingIn); + final account = await _backupService.signIn(); + if (mounted) { + setState(() => _state = _BackupState.idle); + if (account != null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('${account.email} で連携しました')), + ); + } else { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('連携がキャンセルされました')), + ); + } + } + } + + Future _signOut() async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('連携解除'), + content: const Text('Googleアカウントとの連携を解除しますか?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('キャンセル'), + ), + ElevatedButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('解除'), + ), + ], + ), + ); + + if (confirmed == true) { + setState(() => _state = _BackupState.signingOut); + await _backupService.signOut(); + if (mounted) { + setState(() => _state = _BackupState.idle); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('連携を解除しました')), + ); + } + } + } + + Future _createBackup() async { + setState(() => _state = _BackupState.backingUp); + final success = await _backupService.createBackup(); + if (mounted) { + setState(() => _state = _BackupState.idle); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(success ? 'バックアップが完了しました' : 'バックアップに失敗しました'), + backgroundColor: success ? Colors.green : Colors.red, + ), + ); + } + } + + Future _restoreBackup() async { + final hasBackup = await _backupService.hasBackupOnDrive(); + if (!hasBackup && mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('バックアップファイルが見つかりません')), + ); + return; + } + + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Row( + children: [ + Icon(LucideIcons.alertTriangle, color: Colors.orange, size: 24), + const SizedBox(width: 8), + const Text('データ復元'), + ], + ), + content: const Text('現在のデータは上書きされます。\n削除されたデータは元に戻りません。\n\n本当に続行しますか?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('キャンセル'), + ), + ElevatedButton( + onPressed: () => Navigator.pop(context, true), + style: ElevatedButton.styleFrom( + backgroundColor: Theme.of(context).primaryColor, + foregroundColor: Colors.white, + ), + child: const Text('復元'), + ), + ], + ), + ); + + if (confirmed == true) { + setState(() => _state = _BackupState.restoring); + final success = await _backupService.restoreBackup(); + if (mounted) { + setState(() => _state = _BackupState.idle); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(success ? '復元が完了しました' : '復元に失敗しました'), + backgroundColor: success ? Colors.green : Colors.red, + ), + ); + } + } + } + + @override + Widget build(BuildContext context) { + final currentUser = _backupService.currentUser; + final isDark = Theme.of(context).brightness == Brightness.dark; + final isAnyProcessing = _state != _BackupState.idle; + + return Column( + children: [ + _buildSectionHeader(context, widget.title, LucideIcons.cloud), + Card( + color: isDark ? const Color(0xFF1E1E1E) : null, + child: Column( + children: [ + // Google Sign In Status + ListTile( + leading: Icon( + currentUser != null ? LucideIcons.checkCircle2 : LucideIcons.user, + color: currentUser != null ? Colors.green : (isDark ? Colors.orange[300] : Theme.of(context).primaryColor), + ), + title: Text(currentUser == null ? 'Googleアカウント連携' : currentUser.email), + subtitle: Text(currentUser == null ? 'バックアップを利用するには連携が必要です' : '安全にログアウトできます'), + trailing: (_state == _BackupState.signingIn || _state == _BackupState.signingOut) + ? const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2)) + : ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: currentUser == null ? Theme.of(context).primaryColor : Colors.grey[700], + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 16), + ), + onPressed: isAnyProcessing ? null : (currentUser == null ? _signIn : _signOut), + child: Text(currentUser == null ? '連携' : '解除'), + ), + ), + if (currentUser != null) ...[ + const Divider(height: 1), + ListTile( + leading: Icon(LucideIcons.uploadCloud, color: isDark ? Colors.blue[300] : Colors.blue), + title: const Text('バックアップ'), + subtitle: const Text('Google Driveに最新データのみ保存(過去分は上書き)'), + trailing: _state == _BackupState.backingUp + ? const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2)) + : null, + onTap: isAnyProcessing ? null : _createBackup, + ), + const Divider(height: 1), + ListTile( + leading: Icon(LucideIcons.downloadCloud, color: isDark ? Colors.red[300] : Colors.red), + title: const Text('データ復元'), + subtitle: const Text('Google Driveからデータを読み込み(上書き)'), + trailing: _state == _BackupState.restoring + ? const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2)) + : null, + onTap: isAnyProcessing ? null : _restoreBackup, + ), + ], + ], + ), + ), + ], + ); + } + + Widget _buildSectionHeader(BuildContext context, String title, IconData icon) { + final isDark = Theme.of(context).brightness == Brightness.dark; + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 4), + child: Row( + children: [ + Icon(icon, size: 20, color: isDark ? Colors.orange[300] : Theme.of(context).primaryColor), + const SizedBox(width: 8), + Text( + title, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + color: isDark ? Colors.grey[300] : Theme.of(context).primaryColor, + ), + ), + ], + ), + ); + } +} diff --git a/lib/widgets/settings/other_settings_section.dart b/lib/widgets/settings/other_settings_section.dart new file mode 100644 index 0000000..56a797d --- /dev/null +++ b/lib/widgets/settings/other_settings_section.dart @@ -0,0 +1,94 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:lucide_icons/lucide_icons.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import '../../providers/theme_provider.dart'; + +class OtherSettingsSection extends ConsumerStatefulWidget { + final bool showBusinessMode; + final String title; + + const OtherSettingsSection({ + super.key, + this.showBusinessMode = true, + this.title = 'データ・その他', + }); + + @override + ConsumerState createState() => _OtherSettingsSectionState(); +} + +class _OtherSettingsSectionState extends ConsumerState { + String _appVersion = 'Loading...'; + + @override + void initState() { + super.initState(); + _loadAppVersion(); + } + + Future _loadAppVersion() async { + final packageInfo = await PackageInfo.fromPlatform(); + if (mounted) { + setState(() { + _appVersion = 'v${packageInfo.version}+${packageInfo.buildNumber} (Lite)'; + }); + } + } + + @override + Widget build(BuildContext context) { + final userProfile = ref.watch(userProfileProvider); + final isDark = Theme.of(context).brightness == Brightness.dark; + + return Column( + children: [ + _buildSectionHeader(context, widget.title, LucideIcons.database), + Card( + child: Column( + children: [ + if (widget.showBusinessMode) ...[ + ListTile( + leading: Icon(LucideIcons.store, color: isDark ? Colors.orange[300] : Colors.orange), + title: const Text('飲食店モード (Beta)'), + subtitle: const Text('お品書き作成機能など'), + trailing: Switch( + value: userProfile.isBusinessMode, + onChanged: (val) => ref.read(userProfileProvider.notifier).toggleBusinessMode(), + activeColor: Colors.orange, + ), + ), + const Divider(height: 1), + ], + ListTile( + leading: Icon(LucideIcons.info, color: isDark ? Colors.grey[400] : null), + title: const Text('アプリバージョン'), + subtitle: Text(_appVersion), + ), + ], + ), + ), + ], + ); + } + + Widget _buildSectionHeader(BuildContext context, String title, IconData icon) { + final isDark = Theme.of(context).brightness == Brightness.dark; + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 4), + child: Row( + children: [ + Icon(icon, size: 20, color: isDark ? Colors.orange[300] : Theme.of(context).primaryColor), + const SizedBox(width: 8), + Text( + title, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + color: isDark ? Colors.grey[300] : Theme.of(context).primaryColor, + ), + ), + ], + ), + ); + } +} diff --git a/lib/widgets/step_indicator.dart b/lib/widgets/step_indicator.dart new file mode 100644 index 0000000..c9bdc80 --- /dev/null +++ b/lib/widgets/step_indicator.dart @@ -0,0 +1,66 @@ +import 'package:flutter/material.dart'; +import '../theme/app_theme.dart'; + +/// ドット・ステッパー型のステップインジケーター +/// +/// Kintone の申込フローで実績のあるデザインパターンを採用 +/// 数字表記なしで、ドットと線だけで進捗を直感的に表現 +class StepIndicator extends StatelessWidget { + final int currentStep; // 1, 2, 3 + final int totalSteps; // 通常は 3 + + const StepIndicator({ + super.key, + required this.currentStep, + this.totalSteps = 3, + }); + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + children: List.generate(totalSteps * 2 - 1, (index) { + // 偶数インデックス: ドット + // 奇数インデックス: 連結線 + if (index.isEven) { + final stepNumber = index ~/ 2 + 1; + final isActive = stepNumber <= currentStep; + return _buildDot(isActive); + } else { + return _buildLine(); + } + }), + ); + } + + /// ドット (円) を生成 + /// + /// - 完了済み: Posimai Blue で塗りつぶし + /// - 未完了: グレーの枠線のみ + Widget _buildDot(bool isActive) { + return Container( + width: 12, + height: 12, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: isActive ? AppTheme.posimaiBlue : Colors.transparent, + border: Border.all( + color: isActive ? AppTheme.posimaiBlue : Colors.grey[400]!, + width: 2, + ), + ), + ); + } + + /// 連結線を生成 + /// + /// ドット同士を繋ぐ細い線 + Widget _buildLine() { + return Container( + width: 20, + height: 2, + color: Colors.grey[400], + margin: const EdgeInsets.symmetric(horizontal: 4), + ); + } +} diff --git a/linux/.gitignore b/linux/.gitignore new file mode 100644 index 0000000..d3896c9 --- /dev/null +++ b/linux/.gitignore @@ -0,0 +1 @@ +flutter/ephemeral diff --git a/linux/CMakeLists.txt b/linux/CMakeLists.txt new file mode 100644 index 0000000..fb94eb1 --- /dev/null +++ b/linux/CMakeLists.txt @@ -0,0 +1,128 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.13) +project(runner LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "ponshu_room_lite") +# The unique GTK application identifier for this application. See: +# https://wiki.gnome.org/HowDoI/ChooseApplicationID +set(APPLICATION_ID "com.posimai.ponshu_room_lite") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(SET CMP0063 NEW) + +# Load bundled libraries from the lib/ directory relative to the binary. +set(CMAKE_INSTALL_RPATH "$ORIGIN/lib") + +# Root filesystem for cross-building. +if(FLUTTER_TARGET_PLATFORM_SYSROOT) + set(CMAKE_SYSROOT ${FLUTTER_TARGET_PLATFORM_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) + set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY) +endif() + +# Define build configuration options. +if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") +endif() + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_14) + target_compile_options(${TARGET} PRIVATE -Wall -Werror) + target_compile_options(${TARGET} PRIVATE "$<$>:-O3>") + target_compile_definitions(${TARGET} PRIVATE "$<$>:NDEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) + +# Only the install-generated bundle's copy of the executable will launch +# correctly, since the resources must in the right relative locations. To avoid +# people trying to run the unbundled copy, put it in a subdirectory instead of +# the default top-level location. +set_target_properties(${BINARY_NAME} + PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run" +) + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# By default, "installing" just makes a relocatable bundle in the build +# directory. +set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle") +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +# Start with a clean build bundle directory every time. +install(CODE " + file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\") + " COMPONENT Runtime) + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES}) + install(FILES "${bundled_library}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endforeach(bundled_library) + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/linux/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +if(NOT CMAKE_BUILD_TYPE MATCHES "Debug") + install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() diff --git a/linux/flutter/CMakeLists.txt b/linux/flutter/CMakeLists.txt new file mode 100644 index 0000000..d5bd016 --- /dev/null +++ b/linux/flutter/CMakeLists.txt @@ -0,0 +1,88 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.10) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. + +# Serves the same purpose as list(TRANSFORM ... PREPEND ...), +# which isn't available in 3.10. +function(list_prepend LIST_NAME PREFIX) + set(NEW_LIST "") + foreach(element ${${LIST_NAME}}) + list(APPEND NEW_LIST "${PREFIX}${element}") + endforeach(element) + set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE) +endfunction() + +# === Flutter Library === +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) +pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0) +pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0) + +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/lib/libapp.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "fl_basic_message_channel.h" + "fl_binary_codec.h" + "fl_binary_messenger.h" + "fl_dart_project.h" + "fl_engine.h" + "fl_json_message_codec.h" + "fl_json_method_codec.h" + "fl_message_codec.h" + "fl_method_call.h" + "fl_method_channel.h" + "fl_method_codec.h" + "fl_method_response.h" + "fl_plugin_registrar.h" + "fl_plugin_registry.h" + "fl_standard_message_codec.h" + "fl_standard_method_codec.h" + "fl_string_codec.h" + "fl_value.h" + "fl_view.h" + "flutter_linux.h" +) +list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}") +target_link_libraries(flutter INTERFACE + PkgConfig::GTK + PkgConfig::GLIB + PkgConfig::GIO +) +add_dependencies(flutter flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CMAKE_CURRENT_BINARY_DIR}/_phony_ + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh" + ${FLUTTER_TARGET_PLATFORM} ${CMAKE_BUILD_TYPE} + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} +) diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc new file mode 100644 index 0000000..42b1fe4 --- /dev/null +++ b/linux/flutter/generated_plugin_registrant.cc @@ -0,0 +1,19 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + +#include +#include + +void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); + file_selector_plugin_register_with_registrar(file_selector_linux_registrar); + g_autoptr(FlPluginRegistrar) printing_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "PrintingPlugin"); + printing_plugin_register_with_registrar(printing_registrar); +} diff --git a/linux/flutter/generated_plugin_registrant.h b/linux/flutter/generated_plugin_registrant.h new file mode 100644 index 0000000..e0f0a47 --- /dev/null +++ b/linux/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void fl_register_plugins(FlPluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake new file mode 100644 index 0000000..03a2788 --- /dev/null +++ b/linux/flutter/generated_plugins.cmake @@ -0,0 +1,25 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST + file_selector_linux + printing +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/linux/runner/CMakeLists.txt b/linux/runner/CMakeLists.txt new file mode 100644 index 0000000..e97dabc --- /dev/null +++ b/linux/runner/CMakeLists.txt @@ -0,0 +1,26 @@ +cmake_minimum_required(VERSION 3.13) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} + "main.cc" + "my_application.cc" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the application ID. +add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}") + +# Add dependency libraries. Add any application-specific dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter) +target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK) + +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") diff --git a/linux/runner/main.cc b/linux/runner/main.cc new file mode 100644 index 0000000..e7c5c54 --- /dev/null +++ b/linux/runner/main.cc @@ -0,0 +1,6 @@ +#include "my_application.h" + +int main(int argc, char** argv) { + g_autoptr(MyApplication) app = my_application_new(); + return g_application_run(G_APPLICATION(app), argc, argv); +} diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc new file mode 100644 index 0000000..e85860d --- /dev/null +++ b/linux/runner/my_application.cc @@ -0,0 +1,148 @@ +#include "my_application.h" + +#include +#ifdef GDK_WINDOWING_X11 +#include +#endif + +#include "flutter/generated_plugin_registrant.h" + +struct _MyApplication { + GtkApplication parent_instance; + char** dart_entrypoint_arguments; +}; + +G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) + +// Called when first Flutter frame received. +static void first_frame_cb(MyApplication* self, FlView* view) { + gtk_widget_show(gtk_widget_get_toplevel(GTK_WIDGET(view))); +} + +// Implements GApplication::activate. +static void my_application_activate(GApplication* application) { + MyApplication* self = MY_APPLICATION(application); + GtkWindow* window = + GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); + + // Use a header bar when running in GNOME as this is the common style used + // by applications and is the setup most users will be using (e.g. Ubuntu + // desktop). + // If running on X and not using GNOME then just use a traditional title bar + // in case the window manager does more exotic layout, e.g. tiling. + // If running on Wayland assume the header bar will work (may need changing + // if future cases occur). + gboolean use_header_bar = TRUE; +#ifdef GDK_WINDOWING_X11 + GdkScreen* screen = gtk_window_get_screen(window); + if (GDK_IS_X11_SCREEN(screen)) { + const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen); + if (g_strcmp0(wm_name, "GNOME Shell") != 0) { + use_header_bar = FALSE; + } + } +#endif + if (use_header_bar) { + GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); + gtk_widget_show(GTK_WIDGET(header_bar)); + gtk_header_bar_set_title(header_bar, "ponshu_room_lite"); + gtk_header_bar_set_show_close_button(header_bar, TRUE); + gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); + } else { + gtk_window_set_title(window, "ponshu_room_lite"); + } + + gtk_window_set_default_size(window, 1280, 720); + + g_autoptr(FlDartProject) project = fl_dart_project_new(); + fl_dart_project_set_dart_entrypoint_arguments( + project, self->dart_entrypoint_arguments); + + FlView* view = fl_view_new(project); + GdkRGBA background_color; + // Background defaults to black, override it here if necessary, e.g. #00000000 + // for transparent. + gdk_rgba_parse(&background_color, "#000000"); + fl_view_set_background_color(view, &background_color); + gtk_widget_show(GTK_WIDGET(view)); + gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view)); + + // Show the window when Flutter renders. + // Requires the view to be realized so we can start rendering. + g_signal_connect_swapped(view, "first-frame", G_CALLBACK(first_frame_cb), + self); + gtk_widget_realize(GTK_WIDGET(view)); + + fl_register_plugins(FL_PLUGIN_REGISTRY(view)); + + gtk_widget_grab_focus(GTK_WIDGET(view)); +} + +// Implements GApplication::local_command_line. +static gboolean my_application_local_command_line(GApplication* application, + gchar*** arguments, + int* exit_status) { + MyApplication* self = MY_APPLICATION(application); + // Strip out the first argument as it is the binary name. + self->dart_entrypoint_arguments = g_strdupv(*arguments + 1); + + g_autoptr(GError) error = nullptr; + if (!g_application_register(application, nullptr, &error)) { + g_warning("Failed to register: %s", error->message); + *exit_status = 1; + return TRUE; + } + + g_application_activate(application); + *exit_status = 0; + + return TRUE; +} + +// Implements GApplication::startup. +static void my_application_startup(GApplication* application) { + // MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application startup. + + G_APPLICATION_CLASS(my_application_parent_class)->startup(application); +} + +// Implements GApplication::shutdown. +static void my_application_shutdown(GApplication* application) { + // MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application shutdown. + + G_APPLICATION_CLASS(my_application_parent_class)->shutdown(application); +} + +// Implements GObject::dispose. +static void my_application_dispose(GObject* object) { + MyApplication* self = MY_APPLICATION(object); + g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev); + G_OBJECT_CLASS(my_application_parent_class)->dispose(object); +} + +static void my_application_class_init(MyApplicationClass* klass) { + G_APPLICATION_CLASS(klass)->activate = my_application_activate; + G_APPLICATION_CLASS(klass)->local_command_line = + my_application_local_command_line; + G_APPLICATION_CLASS(klass)->startup = my_application_startup; + G_APPLICATION_CLASS(klass)->shutdown = my_application_shutdown; + G_OBJECT_CLASS(klass)->dispose = my_application_dispose; +} + +static void my_application_init(MyApplication* self) {} + +MyApplication* my_application_new() { + // Set the program name to the application ID, which helps various systems + // like GTK and desktop environments map this running application to its + // corresponding .desktop file. This ensures better integration by allowing + // the application to be recognized beyond its binary name. + g_set_prgname(APPLICATION_ID); + + return MY_APPLICATION(g_object_new(my_application_get_type(), + "application-id", APPLICATION_ID, "flags", + G_APPLICATION_NON_UNIQUE, nullptr)); +} diff --git a/linux/runner/my_application.h b/linux/runner/my_application.h new file mode 100644 index 0000000..db16367 --- /dev/null +++ b/linux/runner/my_application.h @@ -0,0 +1,21 @@ +#ifndef FLUTTER_MY_APPLICATION_H_ +#define FLUTTER_MY_APPLICATION_H_ + +#include + +G_DECLARE_FINAL_TYPE(MyApplication, + my_application, + MY, + APPLICATION, + GtkApplication) + +/** + * my_application_new: + * + * Creates a new Flutter-based application. + * + * Returns: a new #MyApplication. + */ +MyApplication* my_application_new(); + +#endif // FLUTTER_MY_APPLICATION_H_ diff --git a/macos/.gitignore b/macos/.gitignore new file mode 100644 index 0000000..746adbb --- /dev/null +++ b/macos/.gitignore @@ -0,0 +1,7 @@ +# Flutter-related +**/Flutter/ephemeral/ +**/Pods/ + +# Xcode-related +**/dgph +**/xcuserdata/ diff --git a/macos/Flutter/Flutter-Debug.xcconfig b/macos/Flutter/Flutter-Debug.xcconfig new file mode 100644 index 0000000..c2efd0b --- /dev/null +++ b/macos/Flutter/Flutter-Debug.xcconfig @@ -0,0 +1 @@ +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/macos/Flutter/Flutter-Release.xcconfig b/macos/Flutter/Flutter-Release.xcconfig new file mode 100644 index 0000000..c2efd0b --- /dev/null +++ b/macos/Flutter/Flutter-Release.xcconfig @@ -0,0 +1 @@ +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift new file mode 100644 index 0000000..85d71e5 --- /dev/null +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -0,0 +1,24 @@ +// +// Generated file. Do not edit. +// + +import FlutterMacOS +import Foundation + +import file_selector_macos +import gal +import google_sign_in_ios +import package_info_plus +import path_provider_foundation +import printing +import shared_preferences_foundation + +func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) + GalPlugin.register(with: registry.registrar(forPlugin: "GalPlugin")) + FLTGoogleSignInPlugin.register(with: registry.registrar(forPlugin: "FLTGoogleSignInPlugin")) + FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) + PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) + PrintingPlugin.register(with: registry.registrar(forPlugin: "PrintingPlugin")) + SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) +} diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..45d674d --- /dev/null +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,705 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXAggregateTarget section */ + 33CC111A2044C6BA0003C045 /* Flutter Assemble */ = { + isa = PBXAggregateTarget; + buildConfigurationList = 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */; + buildPhases = ( + 33CC111E2044C6BF0003C045 /* ShellScript */, + ); + dependencies = ( + ); + name = "Flutter Assemble"; + productName = FLX; + }; +/* End PBXAggregateTarget section */ + +/* Begin PBXBuildFile section */ + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C80D7294CF71000263BE5 /* RunnerTests.swift */; }; + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; }; + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; }; + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC10EC2044A3C60003C045; + remoteInfo = Runner; + }; + 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC111A2044C6BA0003C045; + remoteInfo = FLX; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 33CC110E2044A8840003C045 /* Bundle Framework */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Bundle Framework"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; + 33CC10ED2044A3C60003C045 /* ponshu_room_lite.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "ponshu_room_lite.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; + 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; + 33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = ""; }; + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainFlutterWindow.swift; sourceTree = ""; }; + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = ""; }; + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = ""; }; + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Flutter-Generated.xcconfig"; path = "ephemeral/Flutter-Generated.xcconfig"; sourceTree = ""; }; + 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; + 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; + 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 331C80D2294CF70F00263BE5 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EA2044A3C60003C045 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C80D6294CF71000263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C80D7294CF71000263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 33BA886A226E78AF003329D5 /* Configs */ = { + isa = PBXGroup; + children = ( + 33E5194F232828860026EE4D /* AppInfo.xcconfig */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */, + ); + path = Configs; + sourceTree = ""; + }; + 33CC10E42044A3C60003C045 = { + isa = PBXGroup; + children = ( + 33FAB671232836740065AC1E /* Runner */, + 33CEB47122A05771004F2AC0 /* Flutter */, + 331C80D6294CF71000263BE5 /* RunnerTests */, + 33CC10EE2044A3C60003C045 /* Products */, + D73912EC22F37F3D000D13A0 /* Frameworks */, + ); + sourceTree = ""; + }; + 33CC10EE2044A3C60003C045 /* Products */ = { + isa = PBXGroup; + children = ( + 33CC10ED2044A3C60003C045 /* ponshu_room_lite.app */, + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 33CC11242044D66E0003C045 /* Resources */ = { + isa = PBXGroup; + children = ( + 33CC10F22044A3C60003C045 /* Assets.xcassets */, + 33CC10F42044A3C60003C045 /* MainMenu.xib */, + 33CC10F72044A3C60003C045 /* Info.plist */, + ); + name = Resources; + path = ..; + sourceTree = ""; + }; + 33CEB47122A05771004F2AC0 /* Flutter */ = { + isa = PBXGroup; + children = ( + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */, + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */, + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */, + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */, + ); + path = Flutter; + sourceTree = ""; + }; + 33FAB671232836740065AC1E /* Runner */ = { + isa = PBXGroup; + children = ( + 33CC10F02044A3C60003C045 /* AppDelegate.swift */, + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */, + 33E51913231747F40026EE4D /* DebugProfile.entitlements */, + 33E51914231749380026EE4D /* Release.entitlements */, + 33CC11242044D66E0003C045 /* Resources */, + 33BA886A226E78AF003329D5 /* Configs */, + ); + path = Runner; + sourceTree = ""; + }; + D73912EC22F37F3D000D13A0 /* Frameworks */ = { + isa = PBXGroup; + children = ( + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C80D4294CF70F00263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 331C80D1294CF70F00263BE5 /* Sources */, + 331C80D2294CF70F00263BE5 /* Frameworks */, + 331C80D3294CF70F00263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C80DA294CF71000263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C80D5294CF71000263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 33CC10EC2044A3C60003C045 /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 33CC10E92044A3C60003C045 /* Sources */, + 33CC10EA2044A3C60003C045 /* Frameworks */, + 33CC10EB2044A3C60003C045 /* Resources */, + 33CC110E2044A8840003C045 /* Bundle Framework */, + 3399D490228B24CF009A79C7 /* ShellScript */, + ); + buildRules = ( + ); + dependencies = ( + 33CC11202044C79F0003C045 /* PBXTargetDependency */, + ); + name = Runner; + productName = Runner; + productReference = 33CC10ED2044A3C60003C045 /* ponshu_room_lite.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 33CC10E52044A3C60003C045 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastSwiftUpdateCheck = 0920; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C80D4294CF70F00263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 33CC10EC2044A3C60003C045; + }; + 33CC10EC2044A3C60003C045 = { + CreatedOnToolsVersion = 9.2; + LastSwiftMigration = 1100; + ProvisioningStyle = Automatic; + SystemCapabilities = { + com.apple.Sandbox = { + enabled = 1; + }; + }; + }; + 33CC111A2044C6BA0003C045 = { + CreatedOnToolsVersion = 9.2; + ProvisioningStyle = Manual; + }; + }; + }; + buildConfigurationList = 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 33CC10E42044A3C60003C045; + productRefGroup = 33CC10EE2044A3C60003C045 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 33CC10EC2044A3C60003C045 /* Runner */, + 331C80D4294CF70F00263BE5 /* RunnerTests */, + 33CC111A2044C6BA0003C045 /* Flutter Assemble */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C80D3294CF70F00263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EB2044A3C60003C045 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */, + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3399D490228B24CF009A79C7 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "echo \"$PRODUCT_NAME.app\" > \"$PROJECT_DIR\"/Flutter/ephemeral/.app_filename && \"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh embed\n"; + }; + 33CC111E2044C6BF0003C045 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + Flutter/ephemeral/FlutterInputs.xcfilelist, + ); + inputPaths = ( + Flutter/ephemeral/tripwire, + ); + outputFileListPaths = ( + Flutter/ephemeral/FlutterOutputs.xcfilelist, + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C80D1294CF70F00263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10E92044A3C60003C045 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */, + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */, + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C80DA294CF71000263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC10EC2044A3C60003C045 /* Runner */; + targetProxy = 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */; + }; + 33CC11202044C79F0003C045 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC111A2044C6BA0003C045 /* Flutter Assemble */; + targetProxy = 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 33CC10F42044A3C60003C045 /* MainMenu.xib */ = { + isa = PBXVariantGroup; + children = ( + 33CC10F52044A3C60003C045 /* Base */, + ); + name = MainMenu.xib; + path = Runner; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 331C80DB294CF71000263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.posimai.ponshuRoomLite.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/ponshu_room_lite.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/ponshu_room_lite"; + }; + name = Debug; + }; + 331C80DC294CF71000263BE5 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.posimai.ponshuRoomLite.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/ponshu_room_lite.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/ponshu_room_lite"; + }; + name = Release; + }; + 331C80DD294CF71000263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.posimai.ponshuRoomLite.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/ponshu_room_lite.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/ponshu_room_lite"; + }; + name = Profile; + }; + 338D0CE9231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Profile; + }; + 338D0CEA231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Profile; + }; + 338D0CEB231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Profile; + }; + 33CC10F92044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 33CC10FA2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Release; + }; + 33CC10FC2044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 33CC10FD2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + 33CC111C2044C6BA0003C045 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Debug; + }; + 33CC111D2044C6BA0003C045 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C80DB294CF71000263BE5 /* Debug */, + 331C80DC294CF71000263BE5 /* Release */, + 331C80DD294CF71000263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10F92044A3C60003C045 /* Debug */, + 33CC10FA2044A3C60003C045 /* Release */, + 338D0CE9231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10FC2044A3C60003C045 /* Debug */, + 33CC10FD2044A3C60003C045 /* Release */, + 338D0CEA231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC111C2044C6BA0003C045 /* Debug */, + 33CC111D2044C6BA0003C045 /* Release */, + 338D0CEB231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 33CC10E52044A3C60003C045 /* Project object */; +} diff --git a/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..d739fcb --- /dev/null +++ b/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/macos/Runner.xcworkspace/contents.xcworkspacedata b/macos/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..1d526a1 --- /dev/null +++ b/macos/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/macos/Runner/AppDelegate.swift b/macos/Runner/AppDelegate.swift new file mode 100644 index 0000000..b3c1761 --- /dev/null +++ b/macos/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import Cocoa +import FlutterMacOS + +@main +class AppDelegate: FlutterAppDelegate { + override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + return true + } + + override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { + return true + } +} diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..96d3fee --- /dev/null +++ b/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,68 @@ +{ + "info": { + "version": 1, + "author": "xcode" + }, + "images": [ + { + "size": "16x16", + "idiom": "mac", + "filename": "app_icon_16.png", + "scale": "1x" + }, + { + "size": "16x16", + "idiom": "mac", + "filename": "app_icon_32.png", + "scale": "2x" + }, + { + "size": "32x32", + "idiom": "mac", + "filename": "app_icon_32.png", + "scale": "1x" + }, + { + "size": "32x32", + "idiom": "mac", + "filename": "app_icon_64.png", + "scale": "2x" + }, + { + "size": "128x128", + "idiom": "mac", + "filename": "app_icon_128.png", + "scale": "1x" + }, + { + "size": "128x128", + "idiom": "mac", + "filename": "app_icon_256.png", + "scale": "2x" + }, + { + "size": "256x256", + "idiom": "mac", + "filename": "app_icon_256.png", + "scale": "1x" + }, + { + "size": "256x256", + "idiom": "mac", + "filename": "app_icon_512.png", + "scale": "2x" + }, + { + "size": "512x512", + "idiom": "mac", + "filename": "app_icon_512.png", + "scale": "1x" + }, + { + "size": "512x512", + "idiom": "mac", + "filename": "app_icon_1024.png", + "scale": "2x" + } + ] +} \ No newline at end of file diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png new file mode 100644 index 0000000..070d0ef Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png new file mode 100644 index 0000000..2daa88f Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png new file mode 100644 index 0000000..fdc0e44 Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png new file mode 100644 index 0000000..3c1be86 Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png new file mode 100644 index 0000000..f6dbb02 Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png new file mode 100644 index 0000000..8f01a3c Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png new file mode 100644 index 0000000..953c3cf Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png differ diff --git a/macos/Runner/Base.lproj/MainMenu.xib b/macos/Runner/Base.lproj/MainMenu.xib new file mode 100644 index 0000000..80e867a --- /dev/null +++ b/macos/Runner/Base.lproj/MainMenu.xib @@ -0,0 +1,343 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/macos/Runner/Configs/AppInfo.xcconfig b/macos/Runner/Configs/AppInfo.xcconfig new file mode 100644 index 0000000..6bdff49 --- /dev/null +++ b/macos/Runner/Configs/AppInfo.xcconfig @@ -0,0 +1,14 @@ +// Application-level settings for the Runner target. +// +// This may be replaced with something auto-generated from metadata (e.g., pubspec.yaml) in the +// future. If not, the values below would default to using the project name when this becomes a +// 'flutter create' template. + +// The application's name. By default this is also the title of the Flutter window. +PRODUCT_NAME = ponshu_room_lite + +// The application's bundle identifier +PRODUCT_BUNDLE_IDENTIFIER = com.posimai.ponshuRoomLite + +// The copyright displayed in application information +PRODUCT_COPYRIGHT = Copyright © 2025 com.posimai. All rights reserved. diff --git a/macos/Runner/Configs/Debug.xcconfig b/macos/Runner/Configs/Debug.xcconfig new file mode 100644 index 0000000..36b0fd9 --- /dev/null +++ b/macos/Runner/Configs/Debug.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Debug.xcconfig" +#include "Warnings.xcconfig" diff --git a/macos/Runner/Configs/Release.xcconfig b/macos/Runner/Configs/Release.xcconfig new file mode 100644 index 0000000..dff4f49 --- /dev/null +++ b/macos/Runner/Configs/Release.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Release.xcconfig" +#include "Warnings.xcconfig" diff --git a/macos/Runner/Configs/Warnings.xcconfig b/macos/Runner/Configs/Warnings.xcconfig new file mode 100644 index 0000000..42bcbf4 --- /dev/null +++ b/macos/Runner/Configs/Warnings.xcconfig @@ -0,0 +1,13 @@ +WARNING_CFLAGS = -Wall -Wconditional-uninitialized -Wnullable-to-nonnull-conversion -Wmissing-method-return-type -Woverlength-strings +GCC_WARN_UNDECLARED_SELECTOR = YES +CLANG_UNDEFINED_BEHAVIOR_SANITIZER_NULLABILITY = YES +CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE +CLANG_WARN__DUPLICATE_METHOD_MATCH = YES +CLANG_WARN_PRAGMA_PACK = YES +CLANG_WARN_STRICT_PROTOTYPES = YES +CLANG_WARN_COMMA = YES +GCC_WARN_STRICT_SELECTOR_MATCH = YES +CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = YES +CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES +GCC_WARN_SHADOW = YES +CLANG_WARN_UNREACHABLE_CODE = YES diff --git a/macos/Runner/DebugProfile.entitlements b/macos/Runner/DebugProfile.entitlements new file mode 100644 index 0000000..dddb8a3 --- /dev/null +++ b/macos/Runner/DebugProfile.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.cs.allow-jit + + com.apple.security.network.server + + + diff --git a/macos/Runner/Info.plist b/macos/Runner/Info.plist new file mode 100644 index 0000000..4789daa --- /dev/null +++ b/macos/Runner/Info.plist @@ -0,0 +1,32 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIconFile + + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + NSHumanReadableCopyright + $(PRODUCT_COPYRIGHT) + NSMainNibFile + MainMenu + NSPrincipalClass + NSApplication + + diff --git a/macos/Runner/MainFlutterWindow.swift b/macos/Runner/MainFlutterWindow.swift new file mode 100644 index 0000000..3cc05eb --- /dev/null +++ b/macos/Runner/MainFlutterWindow.swift @@ -0,0 +1,15 @@ +import Cocoa +import FlutterMacOS + +class MainFlutterWindow: NSWindow { + override func awakeFromNib() { + let flutterViewController = FlutterViewController() + let windowFrame = self.frame + self.contentViewController = flutterViewController + self.setFrame(windowFrame, display: true) + + RegisterGeneratedPlugins(registry: flutterViewController) + + super.awakeFromNib() + } +} diff --git a/macos/Runner/Release.entitlements b/macos/Runner/Release.entitlements new file mode 100644 index 0000000..852fa1a --- /dev/null +++ b/macos/Runner/Release.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.security.app-sandbox + + + diff --git a/macos/RunnerTests/RunnerTests.swift b/macos/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..61f3bd1 --- /dev/null +++ b/macos/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Cocoa +import FlutterMacOS +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/pubspec.lock b/pubspec.lock new file mode 100644 index 0000000..0cf55d0 --- /dev/null +++ b/pubspec.lock @@ -0,0 +1,1447 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + _discoveryapis_commons: + dependency: transitive + description: + name: _discoveryapis_commons + sha256: "113c4100b90a5b70a983541782431b82168b3cae166ab130649c36eb3559d498" + url: "https://pub.dev" + source: hosted + version: "1.0.7" + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: "0b2f2bd91ba804e53a61d757b986f89f1f9eaed5b11e4b2f5a2468d86d6c9fc7" + url: "https://pub.dev" + source: hosted + version: "67.0.0" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: "37577842a27e4338429a1cbc32679d508836510b056f1eedf0c8d20e39c1383d" + url: "https://pub.dev" + source: hosted + version: "6.4.1" + analyzer_plugin: + dependency: transitive + description: + name: analyzer_plugin + sha256: "9661b30b13a685efaee9f02e5d01ed9f2b423bd889d28a304d02d704aee69161" + url: "https://pub.dev" + source: hosted + version: "0.11.3" + archive: + dependency: "direct main" + description: + name: archive + sha256: cb6a278ef2dbb298455e1a713bda08524a175630ec643a242c399c932a0a1f7d + url: "https://pub.dev" + source: hosted + version: "3.6.1" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" + url: "https://pub.dev" + source: hosted + version: "2.13.0" + barcode: + dependency: transitive + description: + name: barcode + sha256: "7b6729c37e3b7f34233e2318d866e8c48ddb46c1f7ad01ff7bb2a8de1da2b9f4" + url: "https://pub.dev" + source: hosted + version: "2.2.9" + bidi: + dependency: transitive + description: + name: bidi + sha256: "77f475165e94b261745cf1032c751e2032b8ed92ccb2bf5716036db79320637d" + url: "https://pub.dev" + source: hosted + version: "2.0.13" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + build: + dependency: transitive + description: + name: build + sha256: "80184af8b6cb3e5c1c4ec6d8544d27711700bc3e6d2efad04238c7b5290889f0" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + build_config: + dependency: transitive + description: + name: build_config + sha256: "4ae2de3e1e67ea270081eaee972e1bd8f027d459f249e0f1186730784c2e7e33" + url: "https://pub.dev" + source: hosted + version: "1.1.2" + build_daemon: + dependency: transitive + description: + name: build_daemon + sha256: bf05f6e12cfea92d3c09308d7bcdab1906cd8a179b023269eed00c071004b957 + url: "https://pub.dev" + source: hosted + version: "4.1.1" + build_resolvers: + dependency: transitive + description: + name: build_resolvers + sha256: "339086358431fa15d7eca8b6a36e5d783728cf025e559b834f4609a1fcfb7b0a" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + build_runner: + dependency: "direct dev" + description: + name: build_runner + sha256: "028819cfb90051c6b5440c7e574d1896f8037e3c96cf17aaeb054c9311cfbf4d" + url: "https://pub.dev" + source: hosted + version: "2.4.13" + build_runner_core: + dependency: transitive + description: + name: build_runner_core + sha256: f8126682b87a7282a339b871298cc12009cb67109cfa1614d6436fb0289193e0 + url: "https://pub.dev" + source: hosted + version: "7.3.2" + built_collection: + dependency: transitive + description: + name: built_collection + sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100" + url: "https://pub.dev" + source: hosted + version: "5.1.1" + built_value: + dependency: transitive + description: + name: built_value + sha256: "426cf75afdb23aa74bd4e471704de3f9393f3c7b04c1e2d9c6f1073ae0b8b139" + url: "https://pub.dev" + source: hosted + version: "8.12.1" + camera: + dependency: "direct main" + description: + name: camera + sha256: eefad89f262a873f38d21e5eec853461737ea074d7c9ede39f3ceb135d201cab + url: "https://pub.dev" + source: hosted + version: "0.11.3" + camera_android_camerax: + dependency: transitive + description: + name: camera_android_camerax + sha256: "474d8355961658d43f1c976e2fa1ca715505bea1adbd56df34c581aaa70ec41f" + url: "https://pub.dev" + source: hosted + version: "0.6.26+2" + camera_avfoundation: + dependency: transitive + description: + name: camera_avfoundation + sha256: "087a9fadef20325cb246b4c13344a3ce8e408acfc3e0c665ebff0ec3144d7163" + url: "https://pub.dev" + source: hosted + version: "0.9.22+8" + camera_platform_interface: + dependency: transitive + description: + name: camera_platform_interface + sha256: "98cfc9357e04bad617671b4c1f78a597f25f08003089dd94050709ae54effc63" + url: "https://pub.dev" + source: hosted + version: "2.12.0" + camera_web: + dependency: transitive + description: + name: camera_web + sha256: "3bc7bb1657a0f29c34116453c5d5e528c23efcf5e75aac0a3387cf108040bf65" + url: "https://pub.dev" + source: hosted + version: "0.3.5+2" + characters: + dependency: transitive + description: + name: characters + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" + url: "https://pub.dev" + source: hosted + version: "2.0.4" + ci: + dependency: transitive + description: + name: ci + sha256: "145d095ce05cddac4d797a158bc4cf3b6016d1fe63d8c3d2fbd7212590adca13" + url: "https://pub.dev" + source: hosted + version: "0.1.0" + cli_config: + dependency: transitive + description: + name: cli_config + sha256: ac20a183a07002b700f0c25e61b7ee46b23c309d76ab7b7640a028f18e4d99ec + url: "https://pub.dev" + source: hosted + version: "0.2.0" + cli_util: + dependency: transitive + description: + name: cli_util + sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c + url: "https://pub.dev" + source: hosted + version: "0.4.2" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + code_builder: + dependency: transitive + description: + name: code_builder + sha256: "11654819532ba94c34de52ff5feb52bd81cba1de00ef2ed622fd50295f9d4243" + url: "https://pub.dev" + source: hosted + version: "4.11.0" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + coverage: + dependency: transitive + description: + name: coverage + sha256: "5da775aa218eaf2151c721b16c01c7676fbfdd99cebba2bf64e8b807a28ff94d" + url: "https://pub.dev" + source: hosted + version: "1.15.0" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "701dcfc06da0882883a2657c445103380e53e647060ad8d9dfb710c100996608" + url: "https://pub.dev" + source: hosted + version: "0.3.5+1" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 + url: "https://pub.dev" + source: hosted + version: "1.0.8" + custom_lint: + dependency: transitive + description: + name: custom_lint + sha256: "22bd87a362f433ba6aae127a7bac2838645270737f3721b180916d7c5946cb5d" + url: "https://pub.dev" + source: hosted + version: "0.5.11" + custom_lint_core: + dependency: transitive + description: + name: custom_lint_core + sha256: "2952837953022de610dacb464f045594854ced6506ac7f76af28d4a6490e189b" + url: "https://pub.dev" + source: hosted + version: "0.5.14" + dart_style: + dependency: transitive + description: + name: dart_style + sha256: "99e066ce75c89d6b29903d788a7bb9369cf754f7b24bf70bf4b6d6d6b26853b9" + url: "https://pub.dev" + source: hosted + version: "2.3.6" + equatable: + dependency: transitive + description: + name: equatable + sha256: "567c64b3cb4cf82397aac55f4f0cbd3ca20d77c6c03bedbc4ceaddc08904aef7" + url: "https://pub.dev" + source: hosted + version: "2.0.7" + extension_google_sign_in_as_googleapis_auth: + dependency: "direct main" + description: + name: extension_google_sign_in_as_googleapis_auth + sha256: "0dcb17e399f62e897ac78f0a402a3cb6ab9313ced8b2bf131f684d317e05c9ab" + url: "https://pub.dev" + source: hosted + version: "2.0.13" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + file_selector_linux: + dependency: transitive + description: + name: file_selector_linux + sha256: "2567f398e06ac72dcf2e98a0c95df2a9edd03c2c2e0cacd4780f20cdf56263a0" + url: "https://pub.dev" + source: hosted + version: "0.9.4" + file_selector_macos: + dependency: transitive + description: + name: file_selector_macos + sha256: "5e0bbe9c312416f1787a68259ea1505b52f258c587f12920422671807c4d618a" + url: "https://pub.dev" + source: hosted + version: "0.9.5" + file_selector_platform_interface: + dependency: transitive + description: + name: file_selector_platform_interface + sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85" + url: "https://pub.dev" + source: hosted + version: "2.7.0" + file_selector_windows: + dependency: transitive + description: + name: file_selector_windows + sha256: "62197474ae75893a62df75939c777763d39c2bc5f73ce5b88497208bc269abfd" + url: "https://pub.dev" + source: hosted + version: "0.9.3+5" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" + fl_chart: + dependency: "direct main" + description: + name: fl_chart + sha256: "7ca9a40f4eb85949190e54087be8b4d6ac09dc4c54238d782a34cf1f7c011de9" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_launcher_icons: + dependency: "direct dev" + description: + name: flutter_launcher_icons + sha256: "526faf84284b86a4cb36d20a5e45147747b7563d921373d4ee0559c54fcdbcea" + url: "https://pub.dev" + source: hosted + version: "0.13.1" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" + url: "https://pub.dev" + source: hosted + version: "6.0.0" + flutter_localizations: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: ee8068e0e1cd16c4a82714119918efdeed33b3ba7772c54b5d094ab53f9b7fd1 + url: "https://pub.dev" + source: hosted + version: "2.0.33" + flutter_riverpod: + dependency: "direct main" + description: + name: flutter_riverpod + sha256: "38ec6c303e2c83ee84512f5fc2a82ae311531021938e63d7137eccc107bf3c02" + url: "https://pub.dev" + source: hosted + version: "3.1.0" + flutter_speed_dial: + dependency: "direct main" + description: + name: flutter_speed_dial + sha256: "698a037274a66dbae8697c265440e6acb6ab6cae9ac5f95c749e7944d8f28d41" + url: "https://pub.dev" + source: hosted + version: "7.0.0" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + freezed_annotation: + dependency: transitive + description: + name: freezed_annotation + sha256: c2e2d632dd9b8a2b7751117abcfc2b4888ecfe181bd9fca7170d9ef02e595fe2 + url: "https://pub.dev" + source: hosted + version: "2.4.4" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 + url: "https://pub.dev" + source: hosted + version: "4.0.0" + gal: + dependency: "direct main" + description: + name: gal + sha256: "969598f986789127fd407a750413249e1352116d4c2be66e81837ffeeaafdfee" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" + google_fonts: + dependency: "direct main" + description: + name: google_fonts + sha256: ba03d03bcaa2f6cb7bd920e3b5027181db75ab524f8891c8bc3aa603885b8055 + url: "https://pub.dev" + source: hosted + version: "6.3.3" + google_generative_ai: + dependency: "direct main" + description: + name: google_generative_ai + sha256: "71f613d0247968992ad87a0eb21650a566869757442ba55a31a81be6746e0d1f" + url: "https://pub.dev" + source: hosted + version: "0.4.7" + google_identity_services_web: + dependency: transitive + description: + name: google_identity_services_web + sha256: "5d187c46dc59e02646e10fe82665fc3884a9b71bc1c90c2b8b749316d33ee454" + url: "https://pub.dev" + source: hosted + version: "0.3.3+1" + google_mlkit_commons: + dependency: transitive + description: + name: google_mlkit_commons + sha256: "9990a65f407a3ef6bae646bf10143faa93fec126683771465bc6c0b43fb0e6e9" + url: "https://pub.dev" + source: hosted + version: "0.8.1" + google_mlkit_text_recognition: + dependency: "direct main" + description: + name: google_mlkit_text_recognition + sha256: "179349417066fa2c275d7a6ed6cbceeb7fa265d73aacdb2d732f1a2991face0a" + url: "https://pub.dev" + source: hosted + version: "0.13.1" + google_sign_in: + dependency: "direct main" + description: + name: google_sign_in + sha256: d0a2c3bcb06e607bb11e4daca48bd4b6120f0bbc4015ccebbe757d24ea60ed2a + url: "https://pub.dev" + source: hosted + version: "6.3.0" + google_sign_in_android: + dependency: transitive + description: + name: google_sign_in_android + sha256: d5e23c56a4b84b6427552f1cf3f98f716db3b1d1a647f16b96dbb5b93afa2805 + url: "https://pub.dev" + source: hosted + version: "6.2.1" + google_sign_in_ios: + dependency: transitive + description: + name: google_sign_in_ios + sha256: "102005f498ce18442e7158f6791033bbc15ad2dcc0afa4cf4752e2722a516c96" + url: "https://pub.dev" + source: hosted + version: "5.9.0" + google_sign_in_platform_interface: + dependency: transitive + description: + name: google_sign_in_platform_interface + sha256: "5f6f79cf139c197261adb6ac024577518ae48fdff8e53205c5373b5f6430a8aa" + url: "https://pub.dev" + source: hosted + version: "2.5.0" + google_sign_in_web: + dependency: transitive + description: + name: google_sign_in_web + sha256: "460547beb4962b7623ac0fb8122d6b8268c951cf0b646dd150d60498430e4ded" + url: "https://pub.dev" + source: hosted + version: "0.12.4+4" + googleapis: + dependency: "direct main" + description: + name: googleapis + sha256: "864f222aed3f2ff00b816c675edf00a39e2aaf373d728d8abec30b37bee1a81c" + url: "https://pub.dev" + source: hosted + version: "13.2.0" + googleapis_auth: + dependency: transitive + description: + name: googleapis_auth + sha256: b81fe352cc4a330b3710d2b7ad258d9bcef6f909bb759b306bf42973a7d046db + url: "https://pub.dev" + source: hosted + version: "2.0.0" + graphs: + dependency: transitive + description: + name: graphs + sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + hive: + dependency: "direct main" + description: + name: hive + sha256: "8dcf6db979d7933da8217edcec84e9df1bdb4e4edc7fc77dbd5aa74356d6d941" + url: "https://pub.dev" + source: hosted + version: "2.2.3" + hive_flutter: + dependency: "direct main" + description: + name: hive_flutter + sha256: dca1da446b1d808a51689fb5d0c6c9510c0a2ba01e22805d492c73b68e33eecc + url: "https://pub.dev" + source: hosted + version: "1.1.0" + hive_generator: + dependency: "direct dev" + description: + name: hive_generator + sha256: "06cb8f58ace74de61f63500564931f9505368f45f98958bd7a6c35ba24159db4" + url: "https://pub.dev" + source: hosted + version: "2.0.1" + http: + dependency: transitive + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 + url: "https://pub.dev" + source: hosted + version: "3.2.2" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + image: + dependency: transitive + description: + name: image + sha256: f31d52537dc417fdcde36088fdf11d191026fd5e4fae742491ebd40e5a8bea7d + url: "https://pub.dev" + source: hosted + version: "4.3.0" + image_picker: + dependency: "direct main" + description: + name: image_picker + sha256: "784210112be18ea55f69d7076e2c656a4e24949fa9e76429fe53af0c0f4fa320" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + image_picker_android: + dependency: transitive + description: + name: image_picker_android + sha256: "5e9bf126c37c117cf8094215373c6d561117a3cfb50ebc5add1a61dc6e224677" + url: "https://pub.dev" + source: hosted + version: "0.8.13+10" + image_picker_for_web: + dependency: transitive + description: + name: image_picker_for_web + sha256: "66257a3191ab360d23a55c8241c91a6e329d31e94efa7be9cf7a212e65850214" + url: "https://pub.dev" + source: hosted + version: "3.1.1" + image_picker_ios: + dependency: transitive + description: + name: image_picker_ios + sha256: "956c16a42c0c708f914021666ffcd8265dde36e673c9fa68c81f7d085d9774ad" + url: "https://pub.dev" + source: hosted + version: "0.8.13+3" + image_picker_linux: + dependency: transitive + description: + name: image_picker_linux + sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4" + url: "https://pub.dev" + source: hosted + version: "0.2.2" + image_picker_macos: + dependency: transitive + description: + name: image_picker_macos + sha256: "86f0f15a309de7e1a552c12df9ce5b59fe927e71385329355aec4776c6a8ec91" + url: "https://pub.dev" + source: hosted + version: "0.2.2+1" + image_picker_platform_interface: + dependency: transitive + description: + name: image_picker_platform_interface + sha256: "567e056716333a1647c64bb6bd873cff7622233a5c3f694be28a583d4715690c" + url: "https://pub.dev" + source: hosted + version: "2.11.1" + image_picker_windows: + dependency: transitive + description: + name: image_picker_windows + sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae + url: "https://pub.dev" + source: hosted + version: "0.2.2" + intl: + dependency: "direct main" + description: + name: intl + sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" + url: "https://pub.dev" + source: hosted + version: "0.20.2" + io: + dependency: transitive + description: + name: io + sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b + url: "https://pub.dev" + source: hosted + version: "1.0.5" + js: + dependency: transitive + description: + name: js + sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc" + url: "https://pub.dev" + source: hosted + version: "0.7.2" + json_annotation: + dependency: transitive + description: + name: json_annotation + sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" + url: "https://pub.dev" + source: hosted + version: "4.9.0" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: a5e2b223cb7c9c8efdc663ef484fdd95bb243bff242ef5b13e26883547fce9a0 + url: "https://pub.dev" + source: hosted + version: "6.0.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + lucide_icons: + dependency: "direct main" + description: + name: lucide_icons + sha256: ad24d0fd65707e48add30bebada7d90bff2a1bba0a72d6e9b19d44246b0e83c4 + url: "https://pub.dev" + source: hosted + version: "0.257.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + url: "https://pub.dev" + source: hosted + version: "0.12.17" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + url: "https://pub.dev" + source: hosted + version: "0.11.1" + meta: + dependency: transitive + description: + name: meta + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + url: "https://pub.dev" + source: hosted + version: "1.17.0" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + mobile_scanner: + dependency: "direct main" + description: + name: mobile_scanner + sha256: c6184bf2913dd66be244108c9c27ca04b01caf726321c44b0e7a7a1e32d41044 + url: "https://pub.dev" + source: hosted + version: "7.1.4" + node_preamble: + dependency: transitive + description: + name: node_preamble + sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + package_config: + dependency: transitive + description: + name: package_config + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + url: "https://pub.dev" + source: hosted + version: "2.2.0" + package_info_plus: + dependency: "direct main" + description: + name: package_info_plus + sha256: "16eee997588c60225bda0488b6dcfac69280a6b7a3cf02c741895dd370a02968" + url: "https://pub.dev" + source: hosted + version: "8.3.1" + package_info_plus_platform_interface: + dependency: transitive + description: + name: package_info_plus_platform_interface + sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086" + url: "https://pub.dev" + source: hosted + version: "3.2.1" + path: + dependency: "direct main" + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + path_parsing: + dependency: transitive + description: + name: path_parsing + sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + path_provider: + dependency: "direct main" + description: + name: path_provider + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + url: "https://pub.dev" + source: hosted + version: "2.1.5" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: f2c65e21139ce2c3dad46922be8272bb5963516045659e71bb16e151c93b580e + url: "https://pub.dev" + source: hosted + version: "2.2.22" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "6d13aece7b3f5c5a9731eaf553ff9dcbc2eff41087fd2df587fd0fed9a3eb0c4" + url: "https://pub.dev" + source: hosted + version: "2.5.1" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.dev" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + pdf: + dependency: "direct main" + description: + name: pdf + sha256: "28eacad99bffcce2e05bba24e50153890ad0255294f4dd78a17075a2ba5c8416" + url: "https://pub.dev" + source: hosted + version: "3.11.3" + pdf_widget_wrapper: + dependency: transitive + description: + name: pdf_widget_wrapper + sha256: c930860d987213a3d58c7ec3b7ecf8085c3897f773e8dc23da9cae60a5d6d0f5 + url: "https://pub.dev" + source: hosted + version: "1.0.4" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "1a97266a94f7350d30ae522c0af07890c70b8e62c71e8e3920d1db4d23c057d1" + url: "https://pub.dev" + source: hosted + version: "7.0.1" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + pool: + dependency: transitive + description: + name: pool + sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d" + url: "https://pub.dev" + source: hosted + version: "1.5.2" + printing: + dependency: "direct main" + description: + name: printing + sha256: "482cd5a5196008f984bb43ed0e47cbfdca7373490b62f3b27b3299275bf22a93" + url: "https://pub.dev" + source: hosted + version: "5.14.2" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + pubspec_parse: + dependency: transitive + description: + name: pubspec_parse + sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082" + url: "https://pub.dev" + source: hosted + version: "1.5.0" + qr: + dependency: "direct main" + description: + name: qr + sha256: "5a1d2586170e172b8a8c8470bbbffd5eb0cd38a66c0d77155ea138d3af3a4445" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + qr_flutter: + dependency: "direct main" + description: + name: qr_flutter + sha256: "5095f0fc6e3f71d08adef8feccc8cea4f12eec18a2e31c2e8d82cb6019f4b097" + url: "https://pub.dev" + source: hosted + version: "4.1.0" + reorderable_grid_view: + dependency: "direct main" + description: + name: reorderable_grid_view + sha256: e36c6229a97105a10c79e15ab4b9b14ee9f6c488574ff2be9e858c82af47cda6 + url: "https://pub.dev" + source: hosted + version: "2.2.8" + riverpod: + dependency: transitive + description: + name: riverpod + sha256: "16ff608d21e8ea64364f2b7c049c94a02ab81668f78845862b6e88b71dd4935a" + url: "https://pub.dev" + source: hosted + version: "3.1.0" + riverpod_analyzer_utils: + dependency: transitive + description: + name: riverpod_analyzer_utils + sha256: b6e782db97522de3ad797210bd3babbdb0a67da899aaa6ffbb6572108bdbf48d + url: "https://pub.dev" + source: hosted + version: "1.0.0-dev.1" + riverpod_annotation: + dependency: "direct main" + description: + name: riverpod_annotation + sha256: "79452c7ba2e8f48c7309c73be5aaa101eec5fe7948dfd26659b883fb276858b4" + url: "https://pub.dev" + source: hosted + version: "3.0.0-dev.3" + riverpod_generator: + dependency: "direct dev" + description: + name: riverpod_generator + sha256: "9f3cb7b43e9151fef1cc80031b3ad9fb5d0fe64577cc18e1627061d743823213" + url: "https://pub.dev" + source: hosted + version: "3.0.0-dev.11" + rxdart: + dependency: transitive + description: + name: rxdart + sha256: "0c7c0cedd93788d996e33041ffecda924cc54389199cde4e6a34b440f50044cb" + url: "https://pub.dev" + source: hosted + version: "0.27.7" + screenshot: + dependency: "direct main" + description: + name: screenshot + sha256: "63817697a7835e6ce82add4228e15d233b74d42975c143ad8cfe07009fab866b" + url: "https://pub.dev" + source: hosted + version: "3.0.0" + share_plus: + dependency: "direct main" + description: + name: share_plus + sha256: "14c8860d4de93d3a7e53af51bff479598c4e999605290756bbbe45cf65b37840" + url: "https://pub.dev" + source: hosted + version: "12.0.1" + share_plus_platform_interface: + dependency: transitive + description: + name: share_plus_platform_interface + sha256: "88023e53a13429bd65d8e85e11a9b484f49d4c190abbd96c7932b74d6927cc9a" + url: "https://pub.dev" + source: hosted + version: "6.1.0" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: "2939ae520c9024cb197fc20dee269cd8cdbf564c8b5746374ec6cacdc5169e64" + url: "https://pub.dev" + source: hosted + version: "2.5.4" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: "83af5c682796c0f7719c2bbf74792d113e40ae97981b8f266fa84574573556bc" + url: "https://pub.dev" + source: hosted + version: "2.4.18" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" + url: "https://pub.dev" + source: hosted + version: "2.5.6" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shelf: + dependency: transitive + description: + name: shelf + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 + url: "https://pub.dev" + source: hosted + version: "1.4.2" + shelf_packages_handler: + dependency: transitive + description: + name: shelf_packages_handler + sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + shelf_static: + dependency: transitive + description: + name: shelf_static + sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3 + url: "https://pub.dev" + source: hosted + version: "1.1.3" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: cc36c297b52866d203dbf9332263c94becc2fe0ceaa9681d07b6ef9807023b67 + url: "https://pub.dev" + source: hosted + version: "2.0.1" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_gen: + dependency: transitive + description: + name: source_gen + sha256: "14658ba5f669685cd3d63701d01b31ea748310f7ab854e471962670abcf57832" + url: "https://pub.dev" + source: hosted + version: "1.5.0" + source_helper: + dependency: transitive + description: + name: source_helper + sha256: "86d247119aedce8e63f4751bd9626fc9613255935558447569ad42f9f5b48b3c" + url: "https://pub.dev" + source: hosted + version: "1.3.5" + source_map_stack_trace: + dependency: transitive + description: + name: source_map_stack_trace + sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b + url: "https://pub.dev" + source: hosted + version: "2.1.2" + source_maps: + dependency: transitive + description: + name: source_maps + sha256: "190222579a448b03896e0ca6eca5998fa810fda630c1d65e2f78b3f638f54812" + url: "https://pub.dev" + source: hosted + version: "0.10.13" + source_span: + dependency: transitive + description: + name: source_span + sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" + url: "https://pub.dev" + source: hosted + version: "1.10.1" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + state_notifier: + dependency: transitive + description: + name: state_notifier + sha256: b8677376aa54f2d7c58280d5a007f9e8774f1968d1fb1c096adcb4792fba29bb + url: "https://pub.dev" + source: hosted + version: "1.0.0" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + stream_transform: + dependency: transitive + description: + name: stream_transform + sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 + url: "https://pub.dev" + source: hosted + version: "2.1.1" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test: + dependency: transitive + description: + name: test + sha256: "75906bf273541b676716d1ca7627a17e4c4070a3a16272b7a3dc7da3b9f3f6b7" + url: "https://pub.dev" + source: hosted + version: "1.26.3" + test_api: + dependency: transitive + description: + name: test_api + sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + url: "https://pub.dev" + source: hosted + version: "0.7.7" + test_core: + dependency: transitive + description: + name: test_core + sha256: "0cc24b5ff94b38d2ae73e1eb43cc302b77964fbf67abad1e296025b78deb53d0" + url: "https://pub.dev" + source: hosted + version: "0.6.12" + timing: + dependency: transitive + description: + name: timing + sha256: "62ee18aca144e4a9f29d212f5a4c6a053be252b895ab14b5821996cff4ed90fe" + url: "https://pub.dev" + source: hosted + version: "1.0.2" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a + url: "https://pub.dev" + source: hosted + version: "3.2.2" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: "4bd2b7b4dc4d4d0b94e5babfffbca8eac1a126c7f3d6ecbc1a11013faa3abba2" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f" + url: "https://pub.dev" + source: hosted + version: "3.1.5" + uuid: + dependency: "direct main" + description: + name: uuid + sha256: a11b666489b1954e01d992f3d601b1804a33937b5a8fe677bd26b8a9f96f96e8 + url: "https://pub.dev" + source: hosted + version: "4.5.2" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + url: "https://pub.dev" + source: hosted + version: "2.2.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60" + url: "https://pub.dev" + source: hosted + version: "15.0.2" + watcher: + dependency: transitive + description: + name: watcher + sha256: f52385d4f73589977c80797e60fe51014f7f2b957b5e9a62c3f6ada439889249 + url: "https://pub.dev" + source: hosted + version: "1.2.0" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 + url: "https://pub.dev" + source: hosted + version: "3.0.3" + webkit_inspection_protocol: + dependency: transitive + description: + name: webkit_inspection_protocol + sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + win32: + dependency: transitive + description: + name: win32 + sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e + url: "https://pub.dev" + source: hosted + version: "5.15.0" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + xml: + dependency: transitive + description: + name: xml + sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" + url: "https://pub.dev" + source: hosted + version: "6.6.1" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" +sdks: + dart: ">=3.10.1 <4.0.0" + flutter: ">=3.35.0" diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..de0d1c0 --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,110 @@ +name: ponshu_room_lite +description: "A new Flutter project." +# The following line prevents the package from being accidentally published to +# pub.dev using `flutter pub publish`. This is preferred for private packages. +publish_to: 'none' # Remove this line if you wish to publish to pub.dev + +# The following defines the version and build number for your application. +# A version number is three numbers separated by dots, like 1.2.43 +# followed by an optional build number separated by a +. +# Both the version and the builder number may be overridden in flutter +# build by specifying --build-name and --build-number, respectively. +# In Android, build-name is used as versionName while build-number used as versionCode. +# Read more about Android versioning at https://developer.android.com/studio/publish/versioning +# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion. +# Read more about iOS versioning at +# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html +# In Windows, build-name is used as the major, minor, and patch parts +# of the product and file versions while build-number is used as the build suffix. +version: 1.0.8+16 + +environment: + sdk: ^3.10.1 + +# Dependencies specify other packages that your package needs in order to work. +# To automatically upgrade your package dependencies to the latest versions +# consider running `flutter pub upgrade --major-versions`. Alternatively, +# dependencies can be manually updated by changing the version numbers below to +# the latest version available on pub.dev. To see which dependencies have newer +# versions available, run `flutter pub outdated`. +dependencies: + flutter: + sdk: flutter + + # The following adds the Cupertino Icons font to your application. + # Use with the CupertinoIcons class for iOS style icons. + cupertino_icons: ^1.0.8 + google_fonts: ^6.3.3 + flutter_riverpod: + riverpod_annotation: + hive: ^2.2.3 + hive_flutter: ^1.1.0 + google_generative_ai: ^0.4.7 + + image_picker: ^1.0.7 + lucide_icons: ^0.257.0 + reorderable_grid_view: ^2.2.5 + camera: ^0.11.3 + path_provider: ^2.1.5 + uuid: ^4.5.2 + intl: ^0.20.2 + path: ^1.9.0 + flutter_localizations: + sdk: flutter + fl_chart: ^1.1.1 + pdf: ^3.11.3 + printing: ^5.14.2 + package_info_plus: ^8.1.2 + gal: ^2.3.0 + google_mlkit_text_recognition: ^0.13.1 + shared_preferences: ^2.5.4 + + # Phase 9: Google Drive Backup + googleapis: ^13.2.0 + google_sign_in: ^6.2.1 + extension_google_sign_in_as_googleapis_auth: ^2.0.12 + archive: ^3.6.1 + qr_flutter: ^4.1.0 + mobile_scanner: ^7.1.4 + qr: ^3.0.2 + screenshot: ^3.0.0 + share_plus: ^12.0.1 + flutter_speed_dial: ^7.0.0 + + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^6.0.0 + build_runner: + hive_generator: + riverpod_generator: + flutter_launcher_icons: ^0.13.1 + +flutter_launcher_icons: + android: "launcher_icon" + ios: true + image_path: "assets/images/app_icon.png" + min_sdk_android: 21 + web: + generate: true + image_path: "assets/images/app_icon.png" + background_color: "#hex_code" + theme_color: "#hex_code" + windows: + generate: true + image_path: "assets/images/app_icon.png" + icon_size: 48 + macos: + generate: true + image_path: "assets/images/app_icon.png" + +flutter: + uses-material-design: true + assets: + - assets/fonts/NotoSansJP-Regular.ttf + - assets/images/ + fonts: + - family: NotoSansJP + fonts: + - asset: assets/fonts/NotoSansJP-Regular.ttf diff --git a/test/widget_test.dart b/test/widget_test.dart new file mode 100644 index 0000000..598f666 --- /dev/null +++ b/test/widget_test.dart @@ -0,0 +1,30 @@ +// This is a basic Flutter widget test. +// +// To perform an interaction with a widget in your test, use the WidgetTester +// utility in the flutter_test package. For example, you can send tap and scroll +// gestures. You can also use WidgetTester to find child widgets in the widget +// tree, read text, and verify that the values of widget properties are correct. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:ponshu_room_lite/main.dart'; + +void main() { + testWidgets('Counter increments smoke test', (WidgetTester tester) async { + // Build our app and trigger a frame. + await tester.pumpWidget(const MyApp()); + + // Verify that our counter starts at 0. + expect(find.text('0'), findsOneWidget); + expect(find.text('1'), findsNothing); + + // Tap the '+' icon and trigger a frame. + await tester.tap(find.byIcon(Icons.add)); + await tester.pump(); + + // Verify that our counter has incremented. + expect(find.text('0'), findsNothing); + expect(find.text('1'), findsOneWidget); + }); +} diff --git a/tool/check_models.dart b/tool/check_models.dart new file mode 100644 index 0000000..80a04f8 --- /dev/null +++ b/tool/check_models.dart @@ -0,0 +1,50 @@ +import 'dart:io'; +import 'dart:convert'; +import '../lib/secrets.dart'; + +void main() async { + print('Checking available Gemini models via API...'); + final apiKey = Secrets.geminiApiKey; + + if (apiKey.isEmpty) { + print('API Key is empty in Secrets.'); + return; + } + + // Use v1beta API to list models + final url = 'https://generativelanguage.googleapis.com/v1beta/models?key=$apiKey'; + + try { + final httpClient = HttpClient(); + final request = await httpClient.getUrl(Uri.parse(url)); + final response = await request.close(); + + if (response.statusCode == 200) { + final responseBody = await response.transform(utf8.decoder).join(); + final json = jsonDecode(responseBody); + + print('\n--- Available Models ---'); + if (json['models'] != null) { + for (var model in json['models']) { + // Filter for "generateContent" supported models + final supportedMethods = model['supportedGenerationMethods'] as List?; + if (supportedMethods != null && supportedMethods.contains('generateContent')) { + print('Name: ${model['name']}'); + print('Display Name: ${model['displayName']}'); + print('Description: ${model['description']}'); + print('-------------------------'); + } + } + } else { + print('No models found in response.'); + } + } else { + print('Failed to list models. Status Code: ${response.statusCode}'); + final responseBody = await response.transform(utf8.decoder).join(); + print('Response: $responseBody'); + } + httpClient.close(); + } catch (e) { + print('Error checking models: $e'); + } +} diff --git a/web/favicon.png b/web/favicon.png new file mode 100644 index 0000000..fdc0e44 Binary files /dev/null and b/web/favicon.png differ diff --git a/web/icons/Icon-192.png b/web/icons/Icon-192.png new file mode 100644 index 0000000..8a02c65 Binary files /dev/null and b/web/icons/Icon-192.png differ diff --git a/web/icons/Icon-512.png b/web/icons/Icon-512.png new file mode 100644 index 0000000..8f01a3c Binary files /dev/null and b/web/icons/Icon-512.png differ diff --git a/web/icons/Icon-maskable-192.png b/web/icons/Icon-maskable-192.png new file mode 100644 index 0000000..8a02c65 Binary files /dev/null and b/web/icons/Icon-maskable-192.png differ diff --git a/web/icons/Icon-maskable-512.png b/web/icons/Icon-maskable-512.png new file mode 100644 index 0000000..8f01a3c Binary files /dev/null and b/web/icons/Icon-maskable-512.png differ diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..ad8a841 --- /dev/null +++ b/web/index.html @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + ponshu_room_lite + + + + + + diff --git a/web/manifest.json b/web/manifest.json new file mode 100644 index 0000000..820673a --- /dev/null +++ b/web/manifest.json @@ -0,0 +1,35 @@ +{ + "name": "ponshu_room_lite", + "short_name": "ponshu_room_lite", + "start_url": ".", + "display": "standalone", + "background_color": "#hex_code", + "theme_color": "#hex_code", + "description": "A new Flutter project.", + "orientation": "portrait-primary", + "prefer_related_applications": false, + "icons": [ + { + "src": "icons/Icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icons/Icon-512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "icons/Icon-maskable-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "icons/Icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} \ No newline at end of file diff --git a/windows/.gitignore b/windows/.gitignore new file mode 100644 index 0000000..d492d0d --- /dev/null +++ b/windows/.gitignore @@ -0,0 +1,17 @@ +flutter/ephemeral/ + +# Visual Studio user-specific files. +*.suo +*.user +*.userosscache +*.sln.docstates + +# Visual Studio build-related files. +x64/ +x86/ + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ diff --git a/windows/CMakeLists.txt b/windows/CMakeLists.txt new file mode 100644 index 0000000..5c3fa21 --- /dev/null +++ b/windows/CMakeLists.txt @@ -0,0 +1,108 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.14) +project(ponshu_room_lite LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "ponshu_room_lite") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(VERSION 3.14...3.25) + +# Define build configuration option. +get_property(IS_MULTICONFIG GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG) +if(IS_MULTICONFIG) + set(CMAKE_CONFIGURATION_TYPES "Debug;Profile;Release" + CACHE STRING "" FORCE) +else() + if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") + endif() +endif() +# Define settings for the Profile build mode. +set(CMAKE_EXE_LINKER_FLAGS_PROFILE "${CMAKE_EXE_LINKER_FLAGS_RELEASE}") +set(CMAKE_SHARED_LINKER_FLAGS_PROFILE "${CMAKE_SHARED_LINKER_FLAGS_RELEASE}") +set(CMAKE_C_FLAGS_PROFILE "${CMAKE_C_FLAGS_RELEASE}") +set(CMAKE_CXX_FLAGS_PROFILE "${CMAKE_CXX_FLAGS_RELEASE}") + +# Use Unicode for all projects. +add_definitions(-DUNICODE -D_UNICODE) + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_17) + target_compile_options(${TARGET} PRIVATE /W4 /WX /wd"4100") + target_compile_options(${TARGET} PRIVATE /EHsc) + target_compile_definitions(${TARGET} PRIVATE "_HAS_EXCEPTIONS=0") + target_compile_definitions(${TARGET} PRIVATE "$<$:_DEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# Support files are copied into place next to the executable, so that it can +# run in place. This is done instead of making a separate bundle (as on Linux) +# so that building and running from within Visual Studio will work. +set(BUILD_BUNDLE_DIR "$") +# Make the "install" step default, as it's required to run. +set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD 1) +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +if(PLUGIN_BUNDLED_LIBRARIES) + install(FILES "${PLUGIN_BUNDLED_LIBRARIES}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/windows/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + CONFIGURATIONS Profile;Release + COMPONENT Runtime) diff --git a/windows/flutter/CMakeLists.txt b/windows/flutter/CMakeLists.txt new file mode 100644 index 0000000..903f489 --- /dev/null +++ b/windows/flutter/CMakeLists.txt @@ -0,0 +1,109 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.14) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. +set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper") + +# Set fallback configurations for older versions of the flutter tool. +if (NOT DEFINED FLUTTER_TARGET_PLATFORM) + set(FLUTTER_TARGET_PLATFORM "windows-x64") +endif() + +# === Flutter Library === +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/windows/app.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "flutter_export.h" + "flutter_windows.h" + "flutter_messenger.h" + "flutter_plugin_registrar.h" + "flutter_texture_registrar.h" +) +list(TRANSFORM FLUTTER_LIBRARY_HEADERS PREPEND "${EPHEMERAL_DIR}/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}.lib") +add_dependencies(flutter flutter_assemble) + +# === Wrapper === +list(APPEND CPP_WRAPPER_SOURCES_CORE + "core_implementations.cc" + "standard_codec.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_CORE PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_PLUGIN + "plugin_registrar.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_PLUGIN PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_APP + "flutter_engine.cc" + "flutter_view_controller.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_APP PREPEND "${WRAPPER_ROOT}/") + +# Wrapper sources needed for a plugin. +add_library(flutter_wrapper_plugin STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} +) +apply_standard_settings(flutter_wrapper_plugin) +set_target_properties(flutter_wrapper_plugin PROPERTIES + POSITION_INDEPENDENT_CODE ON) +set_target_properties(flutter_wrapper_plugin PROPERTIES + CXX_VISIBILITY_PRESET hidden) +target_link_libraries(flutter_wrapper_plugin PUBLIC flutter) +target_include_directories(flutter_wrapper_plugin PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_plugin flutter_assemble) + +# Wrapper sources needed for the runner. +add_library(flutter_wrapper_app STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_APP} +) +apply_standard_settings(flutter_wrapper_app) +target_link_libraries(flutter_wrapper_app PUBLIC flutter) +target_include_directories(flutter_wrapper_app PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_app flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +set(PHONY_OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/_phony_") +set_source_files_properties("${PHONY_OUTPUT}" PROPERTIES SYMBOLIC TRUE) +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} + ${PHONY_OUTPUT} + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat" + ${FLUTTER_TARGET_PLATFORM} $ + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} +) diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc new file mode 100644 index 0000000..4691187 --- /dev/null +++ b/windows/flutter/generated_plugin_registrant.cc @@ -0,0 +1,20 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + +#include +#include +#include + +void RegisterPlugins(flutter::PluginRegistry* registry) { + FileSelectorWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FileSelectorWindows")); + GalPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("GalPluginCApi")); + PrintingPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("PrintingPlugin")); +} diff --git a/windows/flutter/generated_plugin_registrant.h b/windows/flutter/generated_plugin_registrant.h new file mode 100644 index 0000000..dc139d8 --- /dev/null +++ b/windows/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void RegisterPlugins(flutter::PluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake new file mode 100644 index 0000000..4441a52 --- /dev/null +++ b/windows/flutter/generated_plugins.cmake @@ -0,0 +1,26 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST + file_selector_windows + gal + printing +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/windows plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/windows plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/windows/runner/CMakeLists.txt b/windows/runner/CMakeLists.txt new file mode 100644 index 0000000..394917c --- /dev/null +++ b/windows/runner/CMakeLists.txt @@ -0,0 +1,40 @@ +cmake_minimum_required(VERSION 3.14) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} WIN32 + "flutter_window.cpp" + "main.cpp" + "utils.cpp" + "win32_window.cpp" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" + "Runner.rc" + "runner.exe.manifest" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the build version. +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION=\"${FLUTTER_VERSION}\"") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MAJOR=${FLUTTER_VERSION_MAJOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MINOR=${FLUTTER_VERSION_MINOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_PATCH=${FLUTTER_VERSION_PATCH}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_BUILD=${FLUTTER_VERSION_BUILD}") + +# Disable Windows macros that collide with C++ standard library functions. +target_compile_definitions(${BINARY_NAME} PRIVATE "NOMINMAX") + +# Add dependency libraries and include directories. Add any application-specific +# dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app) +target_link_libraries(${BINARY_NAME} PRIVATE "dwmapi.lib") +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) diff --git a/windows/runner/Runner.rc b/windows/runner/Runner.rc new file mode 100644 index 0000000..b59bd6e --- /dev/null +++ b/windows/runner/Runner.rc @@ -0,0 +1,121 @@ +// Microsoft Visual C++ generated resource script. +// +#pragma code_page(65001) +#include "resource.h" + +#define APSTUDIO_READONLY_SYMBOLS +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 2 resource. +// +#include "winres.h" + +///////////////////////////////////////////////////////////////////////////// +#undef APSTUDIO_READONLY_SYMBOLS + +///////////////////////////////////////////////////////////////////////////// +// English (United States) resources + +#if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_ENU) +LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US + +#ifdef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// TEXTINCLUDE +// + +1 TEXTINCLUDE +BEGIN + "resource.h\0" +END + +2 TEXTINCLUDE +BEGIN + "#include ""winres.h""\r\n" + "\0" +END + +3 TEXTINCLUDE +BEGIN + "\r\n" + "\0" +END + +#endif // APSTUDIO_INVOKED + + +///////////////////////////////////////////////////////////////////////////// +// +// Icon +// + +// Icon with lowest ID value placed first to ensure application icon +// remains consistent on all systems. +IDI_APP_ICON ICON "resources\\app_icon.ico" + + +///////////////////////////////////////////////////////////////////////////// +// +// Version +// + +#if defined(FLUTTER_VERSION_MAJOR) && defined(FLUTTER_VERSION_MINOR) && defined(FLUTTER_VERSION_PATCH) && defined(FLUTTER_VERSION_BUILD) +#define VERSION_AS_NUMBER FLUTTER_VERSION_MAJOR,FLUTTER_VERSION_MINOR,FLUTTER_VERSION_PATCH,FLUTTER_VERSION_BUILD +#else +#define VERSION_AS_NUMBER 1,0,0,0 +#endif + +#if defined(FLUTTER_VERSION) +#define VERSION_AS_STRING FLUTTER_VERSION +#else +#define VERSION_AS_STRING "1.0.0" +#endif + +VS_VERSION_INFO VERSIONINFO + FILEVERSION VERSION_AS_NUMBER + PRODUCTVERSION VERSION_AS_NUMBER + FILEFLAGSMASK VS_FFI_FILEFLAGSMASK +#ifdef _DEBUG + FILEFLAGS VS_FF_DEBUG +#else + FILEFLAGS 0x0L +#endif + FILEOS VOS__WINDOWS32 + FILETYPE VFT_APP + FILESUBTYPE 0x0L +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904e4" + BEGIN + VALUE "CompanyName", "com.posimai" "\0" + VALUE "FileDescription", "ponshu_room_lite" "\0" + VALUE "FileVersion", VERSION_AS_STRING "\0" + VALUE "InternalName", "ponshu_room_lite" "\0" + VALUE "LegalCopyright", "Copyright (C) 2025 com.posimai. All rights reserved." "\0" + VALUE "OriginalFilename", "ponshu_room_lite.exe" "\0" + VALUE "ProductName", "ponshu_room_lite" "\0" + VALUE "ProductVersion", VERSION_AS_STRING "\0" + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1252 + END +END + +#endif // English (United States) resources +///////////////////////////////////////////////////////////////////////////// + + + +#ifndef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 3 resource. +// + + +///////////////////////////////////////////////////////////////////////////// +#endif // not APSTUDIO_INVOKED diff --git a/windows/runner/flutter_window.cpp b/windows/runner/flutter_window.cpp new file mode 100644 index 0000000..955ee30 --- /dev/null +++ b/windows/runner/flutter_window.cpp @@ -0,0 +1,71 @@ +#include "flutter_window.h" + +#include + +#include "flutter/generated_plugin_registrant.h" + +FlutterWindow::FlutterWindow(const flutter::DartProject& project) + : project_(project) {} + +FlutterWindow::~FlutterWindow() {} + +bool FlutterWindow::OnCreate() { + if (!Win32Window::OnCreate()) { + return false; + } + + RECT frame = GetClientArea(); + + // The size here must match the window dimensions to avoid unnecessary surface + // creation / destruction in the startup path. + flutter_controller_ = std::make_unique( + frame.right - frame.left, frame.bottom - frame.top, project_); + // Ensure that basic setup of the controller was successful. + if (!flutter_controller_->engine() || !flutter_controller_->view()) { + return false; + } + RegisterPlugins(flutter_controller_->engine()); + SetChildContent(flutter_controller_->view()->GetNativeWindow()); + + flutter_controller_->engine()->SetNextFrameCallback([&]() { + this->Show(); + }); + + // Flutter can complete the first frame before the "show window" callback is + // registered. The following call ensures a frame is pending to ensure the + // window is shown. It is a no-op if the first frame hasn't completed yet. + flutter_controller_->ForceRedraw(); + + return true; +} + +void FlutterWindow::OnDestroy() { + if (flutter_controller_) { + flutter_controller_ = nullptr; + } + + Win32Window::OnDestroy(); +} + +LRESULT +FlutterWindow::MessageHandler(HWND hwnd, UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + // Give Flutter, including plugins, an opportunity to handle window messages. + if (flutter_controller_) { + std::optional result = + flutter_controller_->HandleTopLevelWindowProc(hwnd, message, wparam, + lparam); + if (result) { + return *result; + } + } + + switch (message) { + case WM_FONTCHANGE: + flutter_controller_->engine()->ReloadSystemFonts(); + break; + } + + return Win32Window::MessageHandler(hwnd, message, wparam, lparam); +} diff --git a/windows/runner/flutter_window.h b/windows/runner/flutter_window.h new file mode 100644 index 0000000..6da0652 --- /dev/null +++ b/windows/runner/flutter_window.h @@ -0,0 +1,33 @@ +#ifndef RUNNER_FLUTTER_WINDOW_H_ +#define RUNNER_FLUTTER_WINDOW_H_ + +#include +#include + +#include + +#include "win32_window.h" + +// A window that does nothing but host a Flutter view. +class FlutterWindow : public Win32Window { + public: + // Creates a new FlutterWindow hosting a Flutter view running |project|. + explicit FlutterWindow(const flutter::DartProject& project); + virtual ~FlutterWindow(); + + protected: + // Win32Window: + bool OnCreate() override; + void OnDestroy() override; + LRESULT MessageHandler(HWND window, UINT const message, WPARAM const wparam, + LPARAM const lparam) noexcept override; + + private: + // The project to run. + flutter::DartProject project_; + + // The Flutter instance hosted by this window. + std::unique_ptr flutter_controller_; +}; + +#endif // RUNNER_FLUTTER_WINDOW_H_ diff --git a/windows/runner/main.cpp b/windows/runner/main.cpp new file mode 100644 index 0000000..005da07 --- /dev/null +++ b/windows/runner/main.cpp @@ -0,0 +1,43 @@ +#include +#include +#include + +#include "flutter_window.h" +#include "utils.h" + +int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, + _In_ wchar_t *command_line, _In_ int show_command) { + // Attach to console when present (e.g., 'flutter run') or create a + // new console when running with a debugger. + if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) { + CreateAndAttachConsole(); + } + + // Initialize COM, so that it is available for use in the library and/or + // plugins. + ::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); + + flutter::DartProject project(L"data"); + + std::vector command_line_arguments = + GetCommandLineArguments(); + + project.set_dart_entrypoint_arguments(std::move(command_line_arguments)); + + FlutterWindow window(project); + Win32Window::Point origin(10, 10); + Win32Window::Size size(1280, 720); + if (!window.Create(L"ponshu_room_lite", origin, size)) { + return EXIT_FAILURE; + } + window.SetQuitOnClose(true); + + ::MSG msg; + while (::GetMessage(&msg, nullptr, 0, 0)) { + ::TranslateMessage(&msg); + ::DispatchMessage(&msg); + } + + ::CoUninitialize(); + return EXIT_SUCCESS; +} diff --git a/windows/runner/resource.h b/windows/runner/resource.h new file mode 100644 index 0000000..66a65d1 --- /dev/null +++ b/windows/runner/resource.h @@ -0,0 +1,16 @@ +//{{NO_DEPENDENCIES}} +// Microsoft Visual C++ generated include file. +// Used by Runner.rc +// +#define IDI_APP_ICON 101 + +// Next default values for new objects +// +#ifdef APSTUDIO_INVOKED +#ifndef APSTUDIO_READONLY_SYMBOLS +#define _APS_NEXT_RESOURCE_VALUE 102 +#define _APS_NEXT_COMMAND_VALUE 40001 +#define _APS_NEXT_CONTROL_VALUE 1001 +#define _APS_NEXT_SYMED_VALUE 101 +#endif +#endif diff --git a/windows/runner/resources/app_icon.ico b/windows/runner/resources/app_icon.ico new file mode 100644 index 0000000..f5c454b Binary files /dev/null and b/windows/runner/resources/app_icon.ico differ diff --git a/windows/runner/runner.exe.manifest b/windows/runner/runner.exe.manifest new file mode 100644 index 0000000..153653e --- /dev/null +++ b/windows/runner/runner.exe.manifest @@ -0,0 +1,14 @@ + + + + + PerMonitorV2 + + + + + + + + + diff --git a/windows/runner/utils.cpp b/windows/runner/utils.cpp new file mode 100644 index 0000000..3a0b465 --- /dev/null +++ b/windows/runner/utils.cpp @@ -0,0 +1,65 @@ +#include "utils.h" + +#include +#include +#include +#include + +#include + +void CreateAndAttachConsole() { + if (::AllocConsole()) { + FILE *unused; + if (freopen_s(&unused, "CONOUT$", "w", stdout)) { + _dup2(_fileno(stdout), 1); + } + if (freopen_s(&unused, "CONOUT$", "w", stderr)) { + _dup2(_fileno(stdout), 2); + } + std::ios::sync_with_stdio(); + FlutterDesktopResyncOutputStreams(); + } +} + +std::vector GetCommandLineArguments() { + // Convert the UTF-16 command line arguments to UTF-8 for the Engine to use. + int argc; + wchar_t** argv = ::CommandLineToArgvW(::GetCommandLineW(), &argc); + if (argv == nullptr) { + return std::vector(); + } + + std::vector command_line_arguments; + + // Skip the first argument as it's the binary name. + for (int i = 1; i < argc; i++) { + command_line_arguments.push_back(Utf8FromUtf16(argv[i])); + } + + ::LocalFree(argv); + + return command_line_arguments; +} + +std::string Utf8FromUtf16(const wchar_t* utf16_string) { + if (utf16_string == nullptr) { + return std::string(); + } + unsigned int target_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + -1, nullptr, 0, nullptr, nullptr) + -1; // remove the trailing null character + int input_length = (int)wcslen(utf16_string); + std::string utf8_string; + if (target_length == 0 || target_length > utf8_string.max_size()) { + return utf8_string; + } + utf8_string.resize(target_length); + int converted_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + input_length, utf8_string.data(), target_length, nullptr, nullptr); + if (converted_length == 0) { + return std::string(); + } + return utf8_string; +} diff --git a/windows/runner/utils.h b/windows/runner/utils.h new file mode 100644 index 0000000..3879d54 --- /dev/null +++ b/windows/runner/utils.h @@ -0,0 +1,19 @@ +#ifndef RUNNER_UTILS_H_ +#define RUNNER_UTILS_H_ + +#include +#include + +// Creates a console for the process, and redirects stdout and stderr to +// it for both the runner and the Flutter library. +void CreateAndAttachConsole(); + +// Takes a null-terminated wchar_t* encoded in UTF-16 and returns a std::string +// encoded in UTF-8. Returns an empty std::string on failure. +std::string Utf8FromUtf16(const wchar_t* utf16_string); + +// Gets the command line arguments passed in as a std::vector, +// encoded in UTF-8. Returns an empty std::vector on failure. +std::vector GetCommandLineArguments(); + +#endif // RUNNER_UTILS_H_ diff --git a/windows/runner/win32_window.cpp b/windows/runner/win32_window.cpp new file mode 100644 index 0000000..60608d0 --- /dev/null +++ b/windows/runner/win32_window.cpp @@ -0,0 +1,288 @@ +#include "win32_window.h" + +#include +#include + +#include "resource.h" + +namespace { + +/// Window attribute that enables dark mode window decorations. +/// +/// Redefined in case the developer's machine has a Windows SDK older than +/// version 10.0.22000.0. +/// See: https://docs.microsoft.com/windows/win32/api/dwmapi/ne-dwmapi-dwmwindowattribute +#ifndef DWMWA_USE_IMMERSIVE_DARK_MODE +#define DWMWA_USE_IMMERSIVE_DARK_MODE 20 +#endif + +constexpr const wchar_t kWindowClassName[] = L"FLUTTER_RUNNER_WIN32_WINDOW"; + +/// Registry key for app theme preference. +/// +/// A value of 0 indicates apps should use dark mode. A non-zero or missing +/// value indicates apps should use light mode. +constexpr const wchar_t kGetPreferredBrightnessRegKey[] = + L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize"; +constexpr const wchar_t kGetPreferredBrightnessRegValue[] = L"AppsUseLightTheme"; + +// The number of Win32Window objects that currently exist. +static int g_active_window_count = 0; + +using EnableNonClientDpiScaling = BOOL __stdcall(HWND hwnd); + +// Scale helper to convert logical scaler values to physical using passed in +// scale factor +int Scale(int source, double scale_factor) { + return static_cast(source * scale_factor); +} + +// Dynamically loads the |EnableNonClientDpiScaling| from the User32 module. +// This API is only needed for PerMonitor V1 awareness mode. +void EnableFullDpiSupportIfAvailable(HWND hwnd) { + HMODULE user32_module = LoadLibraryA("User32.dll"); + if (!user32_module) { + return; + } + auto enable_non_client_dpi_scaling = + reinterpret_cast( + GetProcAddress(user32_module, "EnableNonClientDpiScaling")); + if (enable_non_client_dpi_scaling != nullptr) { + enable_non_client_dpi_scaling(hwnd); + } + FreeLibrary(user32_module); +} + +} // namespace + +// Manages the Win32Window's window class registration. +class WindowClassRegistrar { + public: + ~WindowClassRegistrar() = default; + + // Returns the singleton registrar instance. + static WindowClassRegistrar* GetInstance() { + if (!instance_) { + instance_ = new WindowClassRegistrar(); + } + return instance_; + } + + // Returns the name of the window class, registering the class if it hasn't + // previously been registered. + const wchar_t* GetWindowClass(); + + // Unregisters the window class. Should only be called if there are no + // instances of the window. + void UnregisterWindowClass(); + + private: + WindowClassRegistrar() = default; + + static WindowClassRegistrar* instance_; + + bool class_registered_ = false; +}; + +WindowClassRegistrar* WindowClassRegistrar::instance_ = nullptr; + +const wchar_t* WindowClassRegistrar::GetWindowClass() { + if (!class_registered_) { + WNDCLASS window_class{}; + window_class.hCursor = LoadCursor(nullptr, IDC_ARROW); + window_class.lpszClassName = kWindowClassName; + window_class.style = CS_HREDRAW | CS_VREDRAW; + window_class.cbClsExtra = 0; + window_class.cbWndExtra = 0; + window_class.hInstance = GetModuleHandle(nullptr); + window_class.hIcon = + LoadIcon(window_class.hInstance, MAKEINTRESOURCE(IDI_APP_ICON)); + window_class.hbrBackground = 0; + window_class.lpszMenuName = nullptr; + window_class.lpfnWndProc = Win32Window::WndProc; + RegisterClass(&window_class); + class_registered_ = true; + } + return kWindowClassName; +} + +void WindowClassRegistrar::UnregisterWindowClass() { + UnregisterClass(kWindowClassName, nullptr); + class_registered_ = false; +} + +Win32Window::Win32Window() { + ++g_active_window_count; +} + +Win32Window::~Win32Window() { + --g_active_window_count; + Destroy(); +} + +bool Win32Window::Create(const std::wstring& title, + const Point& origin, + const Size& size) { + Destroy(); + + const wchar_t* window_class = + WindowClassRegistrar::GetInstance()->GetWindowClass(); + + const POINT target_point = {static_cast(origin.x), + static_cast(origin.y)}; + HMONITOR monitor = MonitorFromPoint(target_point, MONITOR_DEFAULTTONEAREST); + UINT dpi = FlutterDesktopGetDpiForMonitor(monitor); + double scale_factor = dpi / 96.0; + + HWND window = CreateWindow( + window_class, title.c_str(), WS_OVERLAPPEDWINDOW, + Scale(origin.x, scale_factor), Scale(origin.y, scale_factor), + Scale(size.width, scale_factor), Scale(size.height, scale_factor), + nullptr, nullptr, GetModuleHandle(nullptr), this); + + if (!window) { + return false; + } + + UpdateTheme(window); + + return OnCreate(); +} + +bool Win32Window::Show() { + return ShowWindow(window_handle_, SW_SHOWNORMAL); +} + +// static +LRESULT CALLBACK Win32Window::WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + if (message == WM_NCCREATE) { + auto window_struct = reinterpret_cast(lparam); + SetWindowLongPtr(window, GWLP_USERDATA, + reinterpret_cast(window_struct->lpCreateParams)); + + auto that = static_cast(window_struct->lpCreateParams); + EnableFullDpiSupportIfAvailable(window); + that->window_handle_ = window; + } else if (Win32Window* that = GetThisFromHandle(window)) { + return that->MessageHandler(window, message, wparam, lparam); + } + + return DefWindowProc(window, message, wparam, lparam); +} + +LRESULT +Win32Window::MessageHandler(HWND hwnd, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + switch (message) { + case WM_DESTROY: + window_handle_ = nullptr; + Destroy(); + if (quit_on_close_) { + PostQuitMessage(0); + } + return 0; + + case WM_DPICHANGED: { + auto newRectSize = reinterpret_cast(lparam); + LONG newWidth = newRectSize->right - newRectSize->left; + LONG newHeight = newRectSize->bottom - newRectSize->top; + + SetWindowPos(hwnd, nullptr, newRectSize->left, newRectSize->top, newWidth, + newHeight, SWP_NOZORDER | SWP_NOACTIVATE); + + return 0; + } + case WM_SIZE: { + RECT rect = GetClientArea(); + if (child_content_ != nullptr) { + // Size and position the child window. + MoveWindow(child_content_, rect.left, rect.top, rect.right - rect.left, + rect.bottom - rect.top, TRUE); + } + return 0; + } + + case WM_ACTIVATE: + if (child_content_ != nullptr) { + SetFocus(child_content_); + } + return 0; + + case WM_DWMCOLORIZATIONCOLORCHANGED: + UpdateTheme(hwnd); + return 0; + } + + return DefWindowProc(window_handle_, message, wparam, lparam); +} + +void Win32Window::Destroy() { + OnDestroy(); + + if (window_handle_) { + DestroyWindow(window_handle_); + window_handle_ = nullptr; + } + if (g_active_window_count == 0) { + WindowClassRegistrar::GetInstance()->UnregisterWindowClass(); + } +} + +Win32Window* Win32Window::GetThisFromHandle(HWND const window) noexcept { + return reinterpret_cast( + GetWindowLongPtr(window, GWLP_USERDATA)); +} + +void Win32Window::SetChildContent(HWND content) { + child_content_ = content; + SetParent(content, window_handle_); + RECT frame = GetClientArea(); + + MoveWindow(content, frame.left, frame.top, frame.right - frame.left, + frame.bottom - frame.top, true); + + SetFocus(child_content_); +} + +RECT Win32Window::GetClientArea() { + RECT frame; + GetClientRect(window_handle_, &frame); + return frame; +} + +HWND Win32Window::GetHandle() { + return window_handle_; +} + +void Win32Window::SetQuitOnClose(bool quit_on_close) { + quit_on_close_ = quit_on_close; +} + +bool Win32Window::OnCreate() { + // No-op; provided for subclasses. + return true; +} + +void Win32Window::OnDestroy() { + // No-op; provided for subclasses. +} + +void Win32Window::UpdateTheme(HWND const window) { + DWORD light_mode; + DWORD light_mode_size = sizeof(light_mode); + LSTATUS result = RegGetValue(HKEY_CURRENT_USER, kGetPreferredBrightnessRegKey, + kGetPreferredBrightnessRegValue, + RRF_RT_REG_DWORD, nullptr, &light_mode, + &light_mode_size); + + if (result == ERROR_SUCCESS) { + BOOL enable_dark_mode = light_mode == 0; + DwmSetWindowAttribute(window, DWMWA_USE_IMMERSIVE_DARK_MODE, + &enable_dark_mode, sizeof(enable_dark_mode)); + } +} diff --git a/windows/runner/win32_window.h b/windows/runner/win32_window.h new file mode 100644 index 0000000..e901dde --- /dev/null +++ b/windows/runner/win32_window.h @@ -0,0 +1,102 @@ +#ifndef RUNNER_WIN32_WINDOW_H_ +#define RUNNER_WIN32_WINDOW_H_ + +#include + +#include +#include +#include + +// A class abstraction for a high DPI-aware Win32 Window. Intended to be +// inherited from by classes that wish to specialize with custom +// rendering and input handling +class Win32Window { + public: + struct Point { + unsigned int x; + unsigned int y; + Point(unsigned int x, unsigned int y) : x(x), y(y) {} + }; + + struct Size { + unsigned int width; + unsigned int height; + Size(unsigned int width, unsigned int height) + : width(width), height(height) {} + }; + + Win32Window(); + virtual ~Win32Window(); + + // Creates a win32 window with |title| that is positioned and sized using + // |origin| and |size|. New windows are created on the default monitor. Window + // sizes are specified to the OS in physical pixels, hence to ensure a + // consistent size this function will scale the inputted width and height as + // as appropriate for the default monitor. The window is invisible until + // |Show| is called. Returns true if the window was created successfully. + bool Create(const std::wstring& title, const Point& origin, const Size& size); + + // Show the current window. Returns true if the window was successfully shown. + bool Show(); + + // Release OS resources associated with window. + void Destroy(); + + // Inserts |content| into the window tree. + void SetChildContent(HWND content); + + // Returns the backing Window handle to enable clients to set icon and other + // window properties. Returns nullptr if the window has been destroyed. + HWND GetHandle(); + + // If true, closing this window will quit the application. + void SetQuitOnClose(bool quit_on_close); + + // Return a RECT representing the bounds of the current client area. + RECT GetClientArea(); + + protected: + // Processes and route salient window messages for mouse handling, + // size change and DPI. Delegates handling of these to member overloads that + // inheriting classes can handle. + virtual LRESULT MessageHandler(HWND window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Called when CreateAndShow is called, allowing subclass window-related + // setup. Subclasses should return false if setup fails. + virtual bool OnCreate(); + + // Called when Destroy is called. + virtual void OnDestroy(); + + private: + friend class WindowClassRegistrar; + + // OS callback called by message pump. Handles the WM_NCCREATE message which + // is passed when the non-client area is being created and enables automatic + // non-client DPI scaling so that the non-client area automatically + // responds to changes in DPI. All other messages are handled by + // MessageHandler. + static LRESULT CALLBACK WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Retrieves a class instance pointer for |window| + static Win32Window* GetThisFromHandle(HWND const window) noexcept; + + // Update the window frame's theme to match the system theme. + static void UpdateTheme(HWND const window); + + bool quit_on_close_ = false; + + // window handle for top level window. + HWND window_handle_ = nullptr; + + // window handle for hosted content. + HWND child_content_ = nullptr; +}; + +#endif // RUNNER_WIN32_WINDOW_H_