├── .gitignore ├── .metadata ├── Makefile ├── README.md ├── analysis_options.yaml ├── android ├── .gitignore ├── app │ ├── build.gradle │ └── src │ │ ├── debug │ │ └── AndroidManifest.xml │ │ ├── main │ │ ├── AndroidManifest.xml │ │ ├── kotlin │ │ │ └── com │ │ │ │ └── example │ │ │ │ └── zenn_ai_hackathon │ │ │ │ └── MainActivity.kt │ │ └── res │ │ │ ├── drawable-v21 │ │ │ └── launch_background.xml │ │ │ ├── drawable │ │ │ └── launch_background.xml │ │ │ ├── mipmap-hdpi │ │ │ └── ic_launcher.png │ │ │ ├── mipmap-mdpi │ │ │ └── ic_launcher.png │ │ │ ├── mipmap-xhdpi │ │ │ └── ic_launcher.png │ │ │ ├── mipmap-xxhdpi │ │ │ └── ic_launcher.png │ │ │ ├── mipmap-xxxhdpi │ │ │ └── ic_launcher.png │ │ │ ├── values-night │ │ │ └── styles.xml │ │ │ └── values │ │ │ └── styles.xml │ │ └── profile │ │ └── AndroidManifest.xml ├── build.gradle ├── gradle.properties ├── gradle │ └── wrapper │ │ └── gradle-wrapper.properties └── settings.gradle ├── assets ├── .gitkeep └── bannzai.programmer.png ├── firebase.json ├── functions ├── .env.sample ├── .eslintrc.js ├── .gitignore ├── package-lock.json ├── package.json ├── src │ ├── entity │ │ ├── grounding.ts │ │ ├── location.ts │ │ ├── response.ts │ │ ├── task.ts │ │ ├── todo.ts │ │ ├── user.ts │ │ ├── userRequest.ts │ │ └── util │ │ │ └── timestamp.ts │ ├── functions │ │ ├── fillLocation │ │ │ ├── enqueue_task.ts │ │ │ ├── execute_task.ts │ │ │ ├── flow.ts │ │ │ └── input.ts │ │ ├── fillTODOLocation │ │ │ ├── enqueue_task.ts │ │ │ ├── execute_task.ts │ │ │ ├── flow.ts │ │ │ └── input.ts │ │ ├── playground │ │ │ └── flow.ts │ │ ├── taskCreate │ │ │ ├── enqueue_task.ts │ │ │ ├── execute_task.ts │ │ │ ├── flow.ts │ │ │ └── input.ts │ │ └── todoPrepare │ │ │ ├── enqueue_task.ts │ │ │ ├── execute_task.ts │ │ │ ├── flow.ts │ │ │ └── input.ts │ ├── index.ts │ └── utils │ │ ├── ai │ │ ├── ai.ts │ │ └── authPolicy.ts │ │ ├── error │ │ ├── message.ts │ │ └── taskRetry.ts │ │ ├── firebase │ │ ├── converter.ts │ │ ├── firebase.ts │ │ └── gcp.ts │ │ ├── queryLocation.ts │ │ └── stdlib │ │ ├── nullable.ts │ │ └── type_guard.ts ├── tsconfig.dev.json └── tsconfig.json ├── ios ├── .gitignore ├── Firebase │ └── .gitignore ├── Flutter │ ├── AppFrameworkInfo.plist │ ├── Debug.xcconfig │ └── Release.xcconfig ├── Podfile ├── Podfile.lock ├── Runner.xcodeproj │ ├── project.pbxproj │ ├── project.xcworkspace │ │ ├── contents.xcworkspacedata │ │ └── xcshareddata │ │ │ ├── IDEWorkspaceChecks.plist │ │ │ └── WorkspaceSettings.xcsettings │ └── xcshareddata │ │ └── xcschemes │ │ └── Runner.xcscheme ├── Runner.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ ├── IDEWorkspaceChecks.plist │ │ └── WorkspaceSettings.xcsettings ├── Runner │ ├── AppDelegate.swift │ ├── Assets.xcassets │ │ ├── AppIcon.appiconset │ │ │ ├── Contents.json │ │ │ ├── Icon-App-1024x1024@1x.png │ │ │ ├── Icon-App-20x20@1x.png │ │ │ ├── Icon-App-20x20@2x.png │ │ │ ├── Icon-App-20x20@3x.png │ │ │ ├── Icon-App-29x29@1x.png │ │ │ ├── Icon-App-29x29@2x.png │ │ │ ├── Icon-App-29x29@3x.png │ │ │ ├── Icon-App-40x40@1x.png │ │ │ ├── Icon-App-40x40@2x.png │ │ │ ├── Icon-App-40x40@3x.png │ │ │ ├── Icon-App-60x60@2x.png │ │ │ ├── Icon-App-60x60@3x.png │ │ │ ├── Icon-App-76x76@1x.png │ │ │ ├── Icon-App-76x76@2x.png │ │ │ └── Icon-App-83.5x83.5@2x.png │ │ └── LaunchImage.imageset │ │ │ ├── Contents.json │ │ │ ├── LaunchImage.png │ │ │ ├── LaunchImage@2x.png │ │ │ ├── LaunchImage@3x.png │ │ │ └── README.md │ ├── Base.lproj │ │ ├── LaunchScreen.storyboard │ │ └── Main.storyboard │ ├── Info.plist │ └── Runner-Bridging-Header.h └── RunnerTests │ └── RunnerTests.swift ├── lib ├── app.dart ├── components │ ├── alert │ │ ├── discard.dart │ │ ├── help.dart │ │ ├── image_picker.dart │ │ └── ok.dart │ ├── calendar │ │ ├── components │ │ │ └── submit_button.dart │ │ └── form.dart │ ├── error │ │ └── error_alert.dart │ ├── form │ │ └── question_form.dart │ ├── grounding_data │ │ └── list.dart │ ├── loading │ │ ├── bot.dart │ │ ├── indicator.dart │ │ └── loading.dart │ ├── location │ │ └── form.dart │ ├── retry │ │ ├── button.dart │ │ └── page.dart │ ├── todo │ │ ├── help.dart │ │ └── list.dart │ ├── todo_locations │ │ ├── ask.dart │ │ ├── list.dart │ │ └── row.dart │ └── todo_time_required │ │ ├── functions.dart │ │ └── row.dart ├── entity │ ├── app_user.dart │ ├── app_user.freezed.dart │ ├── app_user.g.dart │ ├── grounding_data.dart │ ├── grounding_data.freezed.dart │ ├── grounding_data.g.dart │ ├── location.dart │ ├── location.freezed.dart │ ├── location.g.dart │ ├── location_form.dart │ ├── location_form.freezed.dart │ ├── location_form.g.dart │ ├── remote_config_parameter.dart │ ├── remote_config_parameter.freezed.dart │ ├── remote_config_parameter.g.dart │ ├── task.dart │ ├── task.freezed.dart │ ├── task.g.dart │ ├── timestamp.dart │ ├── todo.dart │ ├── todo.freezed.dart │ └── todo.g.dart ├── features │ ├── home │ │ └── page.dart │ ├── onboarding │ │ ├── components │ │ │ ├── body_1.dart │ │ │ ├── body_2.dart │ │ │ └── event.dart │ │ ├── const.dart │ │ ├── page.dart │ │ └── resolver.dart │ ├── root │ │ ├── page.dart │ │ └── resolver │ │ │ ├── app_user_create.dart │ │ │ ├── app_user_create.g.dart │ │ │ ├── auth.dart │ │ │ ├── auth.g.dart │ │ │ ├── database.dart │ │ │ ├── database.g.dart │ │ │ └── force_update.dart │ ├── task │ │ ├── components │ │ │ ├── location │ │ │ │ ├── ask.dart │ │ │ │ └── location.dart │ │ │ └── time_required │ │ │ │ └── todos.dart │ │ └── page.dart │ ├── tasks │ │ ├── components │ │ │ └── section.dart │ │ └── page.dart │ ├── todo │ │ ├── components │ │ │ └── memo.dart │ │ └── page.dart │ └── todo_locations │ │ └── page.dart ├── main.dart ├── provider │ ├── app_user.dart │ ├── app_user.g.dart │ ├── force_update.dart │ ├── remote_config_parameter.dart │ ├── remote_config_parameter.g.dart │ ├── shared_preferences.dart │ ├── shared_preferences.g.dart │ ├── task.dart │ ├── task.g.dart │ ├── todo.dart │ └── todo.g.dart ├── style │ ├── button.dart │ └── color.dart └── utils │ ├── analytics │ ├── analytics.dart │ └── error.dart │ ├── config │ ├── platform.dart │ ├── remote_config.dart │ └── version.dart │ ├── date_time │ ├── date_time_ext.dart │ ├── date_time_range_ext.dart │ └── weekday.dart │ ├── format │ └── time_of_day.dart │ ├── functions │ └── firebase_functions.dart │ ├── image │ └── image.dart │ ├── log │ └── debug_print.dart │ ├── native │ ├── method_channel.dart │ └── text_field_context_menu.dart │ ├── picker │ ├── time.dart │ └── toolbar.dart │ ├── platform │ └── environment.dart │ ├── shared_preferences │ └── keys.dart │ └── storage │ └── firebase_cloud_storage.dart ├── pubspec.lock ├── pubspec.yaml └── test └── widget_test.dart /.gitignore: -------------------------------------------------------------------------------- 1 | # Miscellaneous 2 | *.class 3 | *.log 4 | *.pyc 5 | *.swp 6 | .DS_Store 7 | .atom/ 8 | .buildlog/ 9 | .history 10 | .svn/ 11 | migrate_working_dir/ 12 | 13 | # IntelliJ related 14 | *.iml 15 | *.ipr 16 | *.iws 17 | .idea/ 18 | 19 | # The .vscode folder contains launch configuration and tasks you configure in 20 | # VS Code which you may wish to be included in version control, so this line 21 | # is commented out by default. 22 | #.vscode/ 23 | 24 | # Flutter/Dart/Pub related 25 | **/doc/api/ 26 | **/ios/Flutter/.last_build_id 27 | .dart_tool/ 28 | .flutter-plugins 29 | .flutter-plugins-dependencies 30 | .pub-cache/ 31 | .pub/ 32 | /build/ 33 | 34 | # Symbolication related 35 | app.*.symbols 36 | 37 | # Obfuscation related 38 | app.*.map.json 39 | 40 | # Android Studio will place build artifacts here 41 | /android/app/debug 42 | /android/app/profile 43 | /android/app/release 44 | 45 | secret.dart 46 | 47 | 48 | #### Backend 49 | 50 | # Logs 51 | logs 52 | *.log 53 | npm-debug.log* 54 | yarn-debug.log* 55 | yarn-error.log* 56 | firebase-debug.log* 57 | firebase-debug.*.log* 58 | 59 | # Firebase cache 60 | .firebase/ 61 | 62 | # Firebase config 63 | 64 | # Uncomment this if you'd like others to create their own Firebase project. 65 | # For a team working on the same Firebase project(s), it is recommended to leave 66 | # it commented so all members can deploy to the same project(s) in .firebaserc. 67 | # .firebaserc 68 | 69 | # Runtime data 70 | pids 71 | *.pid 72 | *.seed 73 | *.pid.lock 74 | 75 | # Directory for instrumented libs generated by jscoverage/JSCover 76 | lib-cov 77 | 78 | # Coverage directory used by tools like istanbul 79 | coverage 80 | 81 | # nyc test coverage 82 | .nyc_output 83 | 84 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 85 | .grunt 86 | 87 | # Bower dependency directory (https://bower.io/) 88 | bower_components 89 | 90 | # node-waf configuration 91 | .lock-wscript 92 | 93 | # Compiled binary addons (http://nodejs.org/api/addons.html) 94 | build/Release 95 | 96 | # Dependency directories 97 | node_modules/ 98 | 99 | # Optional npm cache directory 100 | .npm 101 | 102 | # Optional eslint cache 103 | .eslintcache 104 | 105 | # Optional REPL history 106 | .node_repl_history 107 | 108 | # Output of 'npm pack' 109 | *.tgz 110 | 111 | # Yarn Integrity file 112 | .yarn-integrity 113 | 114 | # dotenv environment variables file 115 | .env 116 | 117 | # dataconnect generated files 118 | .dataconnect 119 | 120 | .firebaserc 121 | 122 | dist 123 | .genkit 124 | 125 | -------------------------------------------------------------------------------- /.metadata: -------------------------------------------------------------------------------- 1 | # This file tracks properties of this Flutter project. 2 | # Used by Flutter tool to assess capabilities and perform upgrades etc. 3 | # 4 | # This file should be version controlled and should not be manually edited. 5 | 6 | version: 7 | revision: "dec2ee5c1f98f8e84a7d5380c05eb8a3d0a81668" 8 | channel: "stable" 9 | 10 | project_type: app 11 | 12 | # Tracks metadata for the flutter migrate command 13 | migration: 14 | platforms: 15 | - platform: root 16 | create_revision: dec2ee5c1f98f8e84a7d5380c05eb8a3d0a81668 17 | base_revision: dec2ee5c1f98f8e84a7d5380c05eb8a3d0a81668 18 | - platform: android 19 | create_revision: dec2ee5c1f98f8e84a7d5380c05eb8a3d0a81668 20 | base_revision: dec2ee5c1f98f8e84a7d5380c05eb8a3d0a81668 21 | - platform: ios 22 | create_revision: dec2ee5c1f98f8e84a7d5380c05eb8a3d0a81668 23 | base_revision: dec2ee5c1f98f8e84a7d5380c05eb8a3d0a81668 24 | 25 | # User provided section 26 | 27 | # List of Local paths (relative to this file) that should be 28 | # ignored by the migrate tool. 29 | # 30 | # Files that are not part of the templates will be ignored by default. 31 | unmanaged_files: 32 | - 'lib/main.dart' 33 | - 'ios/Runner.xcodeproj/project.pbxproj' 34 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: secret 2 | secret: 3 | echo $(FILE_FIREBASE_IOS) | base64 -D > ios/Firebase/GoogleService-Info.plist 4 | echo $(FILE_FIREBASE_ANDROID) | base64 -D > android/app/google-services.json 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 注意点 2 | - 直前にモノレポにしたので、困ったらこっちのリポジトリでバックエンドを立ち上げてください。一応一回はdeployして動作確認してます 3 | * https://github.com/bannzai/zennAIHackathonBackend/ 4 | - `TODOMaker Flutter App` と `TODOMaker Backend` とセットアップ方法が分かれています。すべてこのREADMEに記載されています 5 | 6 | ### 動作 7 | ref: https://zenn.dev/bannzai/articles/12d0443426876e#%E5%8B%95%E4%BD%9C%E3%81%95%E3%81%9B%E3%82%8B%E4%B8%8A%E3%81%A7%E3%81%AE%E6%B3%A8%E6%84%8F%E7%82%B9 8 | - Geminiのエラーで429 Too Many Requestが時たま発生します。Cloud Tasksを利用して並行にGeminiを使用しているので発生しやすくなります。リトライもある程度設定してますが、クライアント側の表示とCloud Tasksを利用しての非同期処理の進捗状況やエラー表示について同期を取る仕組みは用意していません。雑に「時間がかかったら停止してやり直してね」という機能は用意されているので5分とかかかってたらやり直してみてください。一番良いのはGeminiの制限を緩めるようにGoogleにお願いすることです 9 | - GeminiのJSON Modeで string | null の型を指定した場合に 「"null"」って文字が返ってくることがしばしばあります。AIの精度が上がれば解決するのでそのまま表示される場所もあったりします 10 | - 実機では全部動きます。Simulator,Emulatorだと動かなかったり、動作が微妙機能があります。Geo Coding, LocationやCalendarへの追加機能がありそこら辺です 11 | 12 | 13 | # TODOMaker Flutter App 14 | ## Setup 15 | 1. Firebaseプロジェクトを用意してください。バックエンドと一緒なプロジェクトにする必要があります。 16 | 2. iOS・Androidのプロジェクトを作成します。その際にiOS→GoogleService-Info.plist,Android→google-services.json を落としてください 17 | 3. 次の環境変数を用意してください。Makefileを参考に 18 | - FILE_FIREBASE_IOS: GoogleService-Info.plist を base64したもの 19 | - FILE_FIREBASE_ANDROID: google-services.json を base64したもの 20 | 4. `$ make secret` を実行します 21 | 5. 好きな方法でFlutterアプリを起動してください。著者はいつもVSCodeのFlutter extensionで起動しています。CLIだと `$ flutter run`だと思います。参考までに実際使っているVSCodeのlaunch.jsonを貼っておきます 22 | 23 | ``` 24 | { 25 | // Use IntelliSense to learn about possible attributes. 26 | // Hover to view descriptions of existing attributes. 27 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 28 | "version": "0.2.0", 29 | "configurations": [ 30 | { 31 | "name": "zenn_ai_hackathon", 32 | "request": "launch", 33 | "type": "dart" 34 | }, 35 | { 36 | "name": "zenn_ai_hackathon (profile mode)", 37 | "request": "launch", 38 | "type": "dart", 39 | "flutterMode": "profile" 40 | }, 41 | { 42 | "name": "zenn_ai_hackathon (release mode)", 43 | "request": "launch", 44 | "type": "dart", 45 | "flutterMode": "release" 46 | } 47 | ] 48 | } 49 | 50 | ``` 51 | 52 | ## 実行時の注意点 53 | iOSの実機実行はApple Developerのチームに属する必要があったかもしれません(違うかもしれない)。実機実行する際は何かしらエラーが出た場合はその点を疑ってください。Androidに関しては全く覚えてないですが、何もしなくても`$ flutter run` や`$ flutter build appbundle --release` で作成したapkさえインストールできれば起動できる気がします 54 | 55 | 56 | # TODOMaker Backend 57 | ## Setup 58 | 1. node のバージョンはv22系です 59 | ```bash 60 | $ node --version 61 | v22.12.0 62 | ``` 63 | 2. Firebaseプロジェクトを一つ作ってください 64 | 3. 次に `.firebaserc` を作成します。このリポジトリでは .gitignoreしています 65 | 66 | ``` 67 | { 68 | "projects": { 69 | "default": "PROJECT_NAME" ← ここにプロジェクトネーム入れる 70 | } 71 | } 72 | ``` 73 | 74 | ## Env 75 | See [.env.sample](./functions/.env.sample) 76 | 77 | ``` 78 | # localhostで起動する場合は local を設定。あとはdev,prodどちらでも良い 79 | APP_ENV=dev 80 | # 多分ここから取得。https://aistudio.google.com/app/apikey?hl=ja 81 | GOOGLE_GENAI_API_KEY= 82 | # この命名のサービスアカウントがいるのでそれを使います。何必要かは忘れました(Cloud TasksのURLを取得だったかな) 83 | GOOGLE_APPLICATION_CREDENTIALS_SERVICE_ACCOUNT_ID=PROJECT_ID@appspot.gserviceaccount.com 84 | ``` 85 | 86 | ## Development 87 | `Env` を用意します。ただ、localhostで動作確認する場合は APP_ENV=local に設定してください。authが無効になるのでdeploy時は気をつけてください 88 | `functions` ディレクトリに移動して `npm run genkit:start` をします 89 | 90 | ` 91 | $ cd functions 92 | $ npm run genkit:start 93 | ` 94 | 95 | http://localhost:4000/ からgenkitのWeb UIから動作確認できます 96 | 97 | ## Deploy 98 | 99 | functionsディレクトリに移動します。firebase deployをすればdeployされますが、./lib とかも消したい場合は以下のようにしています 100 | 101 | ``` 102 | $ cd functions 103 | $ rm -rf ./lib && npm run build && firebase deploy --only functions 104 | ``` 105 | 106 | また、IAMの権限の問題で何かが必要なことがあったかもしれません。なんの権限か忘れましたが、エラーメッセージに表示されると思います。必要があれば連絡ください 107 | -------------------------------------------------------------------------------- /analysis_options.yaml: -------------------------------------------------------------------------------- 1 | # This file configures the analyzer, which statically analyzes Dart code to 2 | # check for errors, warnings, and lints. 3 | # 4 | # The issues identified by the analyzer are surfaced in the UI of Dart-enabled 5 | # IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be 6 | # invoked from the command line by running `flutter analyze`. 7 | 8 | # The following line activates a set of recommended lints for Flutter apps, 9 | # packages, and plugins designed to encourage good coding practices. 10 | include: package:flutter_lints/flutter.yaml 11 | 12 | linter: 13 | # The lint rules applied to this project can be customized in the 14 | # section below to disable rules from the `package:flutter_lints/flutter.yaml` 15 | # included above or to enable additional rules. A list of all available lints 16 | # and their documentation is published at 17 | # https://dart-lang.github.io/linter/lints/index.html. 18 | # 19 | # Instead of disabling a lint rule for the entire project in the 20 | # section below, it can also be suppressed for a single line of code 21 | # or a specific dart file by using the `// ignore: name_of_lint` and 22 | # `// ignore_for_file: name_of_lint` syntax on the line or in the file 23 | # producing the lint. 24 | rules: 25 | always_declare_return_types: true 26 | prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule 27 | # avoid_print: false # Uncomment to disable the `avoid_print` rule 28 | 29 | # Additional information about this file can be found at 30 | # https://dart.dev/guides/language/analysis-options 31 | analyzer: 32 | plugins: 33 | - custom_lint 34 | errors: 35 | missing_required_param: error 36 | missing_return: error 37 | invalid_annotation_target: ignore 38 | exclude: 39 | - "lib/**/*.g.dart" 40 | - "lib/**/*.freezed.dart" -------------------------------------------------------------------------------- /android/.gitignore: -------------------------------------------------------------------------------- 1 | gradle-wrapper.jar 2 | /.gradle 3 | /captures/ 4 | /gradlew 5 | /gradlew.bat 6 | /local.properties 7 | GeneratedPluginRegistrant.java 8 | 9 | # Remember to never publicly share your keystore. 10 | # See https://flutter.dev/to/reference-keystore 11 | key.properties 12 | **/*.keystore 13 | **/*.jks 14 | 15 | google-services.json 16 | -------------------------------------------------------------------------------- /android/app/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id "com.android.application" 3 | id "kotlin-android" 4 | // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. 5 | id "dev.flutter.flutter-gradle-plugin" 6 | id 'com.google.gms.google-services' 7 | } 8 | 9 | dependencies { 10 | // Import the Firebase BoM 11 | implementation platform('com.google.firebase:firebase-bom:33.9.0') 12 | 13 | // TODO: Add the dependencies for Firebase products you want to use 14 | // When using the BoM, don't specify versions in Firebase dependencies 15 | implementation 'com.google.firebase:firebase-analytics' 16 | 17 | 18 | // Add the dependencies for any other desired Firebase products 19 | // https://firebase.google.com/docs/android/setup#available-libraries 20 | } 21 | 22 | android { 23 | namespace = "com.bannzai.todomaker" 24 | compileSdk = 35 25 | ndkVersion = flutter.ndkVersion 26 | 27 | compileOptions { 28 | sourceCompatibility = JavaVersion.VERSION_1_8 29 | targetCompatibility = JavaVersion.VERSION_1_8 30 | } 31 | 32 | kotlinOptions { 33 | jvmTarget = JavaVersion.VERSION_1_8 34 | } 35 | 36 | defaultConfig { 37 | // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). 38 | applicationId = "com.bannzai.todomaker" 39 | // You can update the following values to match your application needs. 40 | // For more information, see: https://flutter.dev/to/review-gradle-config. 41 | minSdk = 23 42 | targetSdk = 35 43 | versionCode = flutter.versionCode 44 | versionName = flutter.versionName 45 | } 46 | 47 | buildTypes { 48 | release { 49 | // TODO: Add your own signing config for the release build. 50 | // Signing with the debug keys for now, so `flutter run --release` works. 51 | signingConfig = signingConfigs.debug 52 | } 53 | } 54 | } 55 | 56 | flutter { 57 | source = "../.." 58 | } 59 | -------------------------------------------------------------------------------- /android/app/src/debug/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /android/app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 14 | 23 | 27 | 31 | 32 | 33 | 34 | 35 | 36 | 38 | 41 | 42 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | -------------------------------------------------------------------------------- /android/app/src/main/kotlin/com/example/zenn_ai_hackathon/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package com.bannzai.todomaker 2 | 3 | import io.flutter.embedding.android.FlutterActivity 4 | 5 | class MainActivity: FlutterActivity() 6 | -------------------------------------------------------------------------------- /android/app/src/main/res/drawable-v21/launch_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 12 | 13 | -------------------------------------------------------------------------------- /android/app/src/main/res/drawable/launch_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 12 | 13 | -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bannzai/zenn_ai_hackathon/81d4f33f4337a554ac9792d0ac880c45f573cabf/android/app/src/main/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bannzai/zenn_ai_hackathon/81d4f33f4337a554ac9792d0ac880c45f573cabf/android/app/src/main/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bannzai/zenn_ai_hackathon/81d4f33f4337a554ac9792d0ac880c45f573cabf/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bannzai/zenn_ai_hackathon/81d4f33f4337a554ac9792d0ac880c45f573cabf/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bannzai/zenn_ai_hackathon/81d4f33f4337a554ac9792d0ac880c45f573cabf/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/values-night/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 15 | 18 | 19 | -------------------------------------------------------------------------------- /android/app/src/main/res/values/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 15 | 18 | 19 | -------------------------------------------------------------------------------- /android/app/src/profile/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /android/build.gradle: -------------------------------------------------------------------------------- 1 | allprojects { 2 | repositories { 3 | google() 4 | mavenCentral() 5 | } 6 | } 7 | 8 | rootProject.buildDir = "../build" 9 | subprojects { 10 | project.buildDir = "${rootProject.buildDir}/${project.name}" 11 | } 12 | subprojects { 13 | project.evaluationDependsOn(":app") 14 | } 15 | 16 | tasks.register("clean", Delete) { 17 | delete rootProject.buildDir 18 | } 19 | -------------------------------------------------------------------------------- /android/gradle.properties: -------------------------------------------------------------------------------- 1 | org.gradle.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=2G -XX:+HeapDumpOnOutOfMemoryError 2 | android.useAndroidX=true 3 | android.enableJetifier=true 4 | -------------------------------------------------------------------------------- /android/gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | zipStoreBase=GRADLE_USER_HOME 4 | zipStorePath=wrapper/dists 5 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.3-all.zip 6 | -------------------------------------------------------------------------------- /android/settings.gradle: -------------------------------------------------------------------------------- 1 | pluginManagement { 2 | def flutterSdkPath = { 3 | def properties = new Properties() 4 | file("local.properties").withInputStream { properties.load(it) } 5 | def flutterSdkPath = properties.getProperty("flutter.sdk") 6 | assert flutterSdkPath != null, "flutter.sdk not set in local.properties" 7 | return flutterSdkPath 8 | }() 9 | 10 | includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") 11 | 12 | repositories { 13 | google() 14 | mavenCentral() 15 | gradlePluginPortal() 16 | } 17 | } 18 | 19 | plugins { 20 | id "dev.flutter.flutter-plugin-loader" version "1.0.0" 21 | id "com.android.application" version "8.1.0" apply false 22 | id "org.jetbrains.kotlin.android" version "2.1.10" apply false 23 | id 'com.google.gms.google-services' version '4.4.2' apply false 24 | } 25 | 26 | include ":app" 27 | -------------------------------------------------------------------------------- /assets/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bannzai/zenn_ai_hackathon/81d4f33f4337a554ac9792d0ac880c45f573cabf/assets/.gitkeep -------------------------------------------------------------------------------- /assets/bannzai.programmer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bannzai/zenn_ai_hackathon/81d4f33f4337a554ac9792d0ac880c45f573cabf/assets/bannzai.programmer.png -------------------------------------------------------------------------------- /firebase.json: -------------------------------------------------------------------------------- 1 | { 2 | "functions": [ 3 | { 4 | "source": "functions", 5 | "codebase": "default", 6 | "predeploy": [ 7 | "npm --prefix \"$RESOURCE_DIR\" run lint", 8 | "npm --prefix \"$RESOURCE_DIR\" run build" 9 | ] 10 | } 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /functions/.env.sample: -------------------------------------------------------------------------------- 1 | 2 | # localhostで起動する場合は local を設定。あとはdev,prodどちらでも良い 3 | APP_ENV=dev 4 | # 多分ここから取得。https://aistudio.google.com/app/apikey?hl=ja 5 | GOOGLE_GENAI_API_KEY= 6 | # この命名のサービスアカウントがいるのでそれを使います。何必要かは忘れました(Cloud TasksのURLを取得だったかな) 7 | GOOGLE_APPLICATION_CREDENTIALS_SERVICE_ACCOUNT_ID=PROJECT_ID@appspot.gserviceaccount.com 8 | -------------------------------------------------------------------------------- /functions/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | node: true, 5 | es2021: true, 6 | }, 7 | extends: [ 8 | "eslint:recommended", 9 | "plugin:@typescript-eslint/recommended", 10 | "prettier", 11 | ], 12 | parser: "@typescript-eslint/parser", 13 | parserOptions: { 14 | ecmaVersion: "latest", 15 | }, 16 | plugins: ["@typescript-eslint"], 17 | ignorePatterns: ["*.test.ts", "*.test.js", "lib"], 18 | rules: { 19 | "@typescript-eslint/no-explicit-any": "off", 20 | }, 21 | }; 22 | -------------------------------------------------------------------------------- /functions/.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled JavaScript files 2 | lib/**/*.js 3 | lib/**/*.js.map 4 | 5 | # TypeScript v1 declaration files 6 | typings/ 7 | 8 | # Node.js dependency directory 9 | node_modules/ 10 | *.local 11 | 12 | service-account.json 13 | -------------------------------------------------------------------------------- /functions/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "main": "lib/index.js", 3 | "scripts": { 4 | "genkit:start": "genkit start -- tsx --watch src/index.ts", 5 | "lint": "eslint --ext .js,.ts .", 6 | "lint:fix": "eslint --ext .js,.ts . --fix", 7 | "format": "prettier --write .", 8 | "build": "tsc", 9 | "build:watch": "tsc --watch", 10 | "serve": "npm run build && firebase emulators:start --only functions", 11 | "shell": "npm run build && firebase functions:shell", 12 | "start": "npm run shell", 13 | "deploy": "firebase deploy --only functions", 14 | "logs": "firebase functions:log" 15 | }, 16 | "name": "functions", 17 | "engines": { 18 | "node": "22" 19 | }, 20 | "dependencies": { 21 | "@genkit-ai/ai": "^0.9.12", 22 | "@genkit-ai/firebase": "^0.9.12", 23 | "@genkit-ai/flow": "^0.5.17", 24 | "@genkit-ai/vertexai": "^0.9.12", 25 | "@google/generative-ai": "^0.21.0", 26 | "@types/express": "^5.0.0", 27 | "express": "^4.21.2", 28 | "firebase-admin": "^13.0.2", 29 | "firebase-functions": "^6.0.1", 30 | "genkit": "^0.9.12", 31 | "google-auth-library": "^9.15.1", 32 | "uuid": "^11.0.5", 33 | "zod": "^3.24.1", 34 | "zod-to-json-schema": "^3.24.1" 35 | }, 36 | "devDependencies": { 37 | "@typescript-eslint/eslint-plugin": "^5.12.0", 38 | "@typescript-eslint/parser": "^5.12.0", 39 | "eslint": "^8.9.0", 40 | "eslint-config-google": "^0.14.0", 41 | "eslint-config-prettier": "^10.0.1", 42 | "eslint-plugin-import": "^2.25.4", 43 | "eslint-plugin-prettier": "^5.2.3", 44 | "firebase-functions-test": "^3.1.0", 45 | "genkit-cli": "^0.9.12", 46 | "prettier": "^3.4.2", 47 | "tsx": "^4.19.2", 48 | "typescript": "^5.7.3" 49 | }, 50 | "private": true 51 | } 52 | -------------------------------------------------------------------------------- /functions/src/entity/grounding.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | export const GroundingDataSchema = z.object({ 4 | url: z.string().optional(), 5 | title: z.string().optional(), 6 | index: z.number().optional(), 7 | }); 8 | 9 | export type GroundingData = z.infer; 10 | -------------------------------------------------------------------------------- /functions/src/entity/location.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | export const LocationSchema = z.object({ 4 | name: z.string().describe("場所の名称"), 5 | postalCode: z.string().nullable().describe("郵便番号"), 6 | address: z.string().nullable().describe("住所。郵便番号を除く"), 7 | tel: z.string().nullable().describe("電話番号"), 8 | email: z.string().nullable().describe("メールアドレス"), 9 | }); 10 | -------------------------------------------------------------------------------- /functions/src/entity/response.ts: -------------------------------------------------------------------------------- 1 | import * as z from "zod"; 2 | import { errorMessage } from "../utils/error/message"; 3 | 4 | export const DataResponseSchema = z.object({ 5 | result: z.literal("OK"), 6 | statusCode: z.number().optional(), 7 | data: z.unknown(), 8 | }); 9 | 10 | export const ErrorResponseSchema = z.object({ 11 | result: z.literal("NG"), 12 | statusCode: z.number().optional(), 13 | error: z.object({ 14 | message: z.string(), 15 | }), 16 | }); 17 | 18 | export const AppResponseSchema = z.union([ 19 | DataResponseSchema, 20 | ErrorResponseSchema, 21 | ]); 22 | 23 | export type DataResponse = z.infer; 24 | export type ErrorResponse = z.infer; 25 | export type AppResponse = z.infer; 26 | 27 | export function errorResponse(error: unknown): ErrorResponse { 28 | return { 29 | result: "NG", 30 | statusCode: 500, 31 | error: { message: errorMessage(error) }, 32 | }; 33 | } 34 | -------------------------------------------------------------------------------- /functions/src/entity/task.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { GroundingDataSchema } from "./grounding"; 3 | import { 4 | FirestoreTimestampSchema, 5 | ServerTimestampSchema, 6 | } from "./util/timestamp"; 7 | import { nullable } from "../utils/stdlib/nullable"; 8 | import { LocationSchema } from "./location"; 9 | 10 | export const TaskPreparedSchema = z 11 | .object({ 12 | status: z.literal("prepared"), 13 | id: z.string(), 14 | userID: z.string(), 15 | // 質問の内容 16 | question: z.string(), 17 | // TODOの質問の内容の回答をAIに渡して、AIが回答した内容 18 | todosAITextResponseMarkdown: z.string(), 19 | // TODOのAIの回答のソースとなったもの 20 | todosGroundings: z.array(GroundingDataSchema), 21 | // 質問の内容を短く回答したもの 22 | shortAnswer: z.string(), 23 | // 質問の内容の対象となるトピック。例) question: 「確定申告の方法」だと「確定申告」 24 | topic: z.string(), 25 | // 質問の内容の対象となるトピックについての解説 26 | definitionAITextResponse: z.string(), 27 | // TODOのAIの回答のソースとなったもの 28 | definitionGroundings: z.array(GroundingDataSchema), 29 | completed: z.boolean(), 30 | // タスクの代表的な場所 31 | // 未処理の場合はnull。処理完了の場合は空配列の可能性がある 32 | locations: z.array(LocationSchema).nullish(), 33 | locationsAITextResponse: z.string().nullish(), 34 | locationsGroundings: z.array(GroundingDataSchema).nullish(), 35 | 36 | preparedDateTime: FirestoreTimestampSchema, 37 | }) 38 | .merge(ServerTimestampSchema); 39 | 40 | export const TaskPreparingSchema = nullable(TaskPreparedSchema) 41 | .partial() 42 | .required({ 43 | id: true, 44 | userID: true, 45 | question: true, 46 | }) 47 | .omit({ 48 | preparedDateTime: true, 49 | completed: true, 50 | }) 51 | .merge( 52 | z.object({ 53 | status: z.literal("preparing"), 54 | }) 55 | ); 56 | export const TaskSchema = z.union([TaskPreparedSchema, TaskPreparingSchema]); 57 | 58 | export type Task = z.infer; 59 | export type TaskPrepared = z.infer; 60 | export type TaskPreparing = z.infer; 61 | -------------------------------------------------------------------------------- /functions/src/entity/todo.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { GroundingDataSchema } from "./grounding"; 3 | import { ServerTimestampSchema } from "./util/timestamp"; 4 | import { LocationSchema } from "./location"; 5 | 6 | export const TODOSchema = z 7 | .object({ 8 | id: z.string(), 9 | userID: z.string(), 10 | taskID: z.string(), 11 | content: z.string().describe("TODOの内容"), 12 | supplement: z.string().describe("補足情報"), 13 | aiTextResponseMarkdown: z.string().nullable(), 14 | groundings: z.array(GroundingDataSchema).nullable(), 15 | // TODOの代表的な場所 16 | // 未処理の場合はnull。処理完了の場合は空配列の可能性がある 17 | locations: z.array(LocationSchema).nullable(), 18 | locationsAITextResponse: z.string().nullable(), 19 | locationsGroundings: z.array(GroundingDataSchema).nullable(), 20 | 21 | // 所要時間(秒) 22 | timeRequired: z.number().describe("所要時間(秒)").nullish(), 23 | timeRequiredAITextResponse: z.string().nullish(), 24 | timeRequiredGroundings: z.array(GroundingDataSchema).nullish(), 25 | }) 26 | .merge(ServerTimestampSchema); 27 | 28 | export type TODO = z.infer; 29 | -------------------------------------------------------------------------------- /functions/src/entity/user.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | // Firestore schema definitions using zod 4 | export const UserSchema = z.object({ 5 | uid: z.string(), 6 | createdAt: z.string().datetime(), 7 | lastLogin: z.string().datetime(), 8 | }); 9 | 10 | export type User = z.infer; -------------------------------------------------------------------------------- /functions/src/entity/userRequest.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | export const UserRequestSchema = z.object({ 4 | userID: z.string(), 5 | }); 6 | 7 | export type UserRequest = z.infer; 8 | -------------------------------------------------------------------------------- /functions/src/entity/util/timestamp.ts: -------------------------------------------------------------------------------- 1 | import { Timestamp } from "firebase-admin/firestore"; 2 | import { z } from "zod"; 3 | 4 | export const FirestoreTimestampSchema = z.object({ 5 | seconds: z.number(), 6 | nanoseconds: z.number(), 7 | }); 8 | 9 | export type FirestoreTimestamp = z.infer; 10 | 11 | // 動作未確認 12 | export function rawFirsetoreTimestamp( 13 | timestamp: FirestoreTimestamp 14 | ): Timestamp { 15 | return new Timestamp(timestamp.seconds, timestamp.nanoseconds); 16 | } 17 | 18 | // NOTE: Firestore の Timestamp を JSON に変換する。z.instanceOf(Timestamp)をそのままスキーマとして使い、JSONで返却すると { _seconds, _nanoseconds } という形式になるため、この関数を使って変換する 19 | export function firestoreTimestampJSON( 20 | timestamp: Timestamp 21 | ): FirestoreTimestamp { 22 | return { 23 | seconds: timestamp.seconds, 24 | nanoseconds: timestamp.nanoseconds, 25 | }; 26 | } 27 | 28 | export const ServerTimestampSchema = z.object({ 29 | serverCreatedDateTime: FirestoreTimestampSchema, 30 | serverUpdatedDateTime: FirestoreTimestampSchema, 31 | }); 32 | 33 | export type ServerTimestamp = z.infer; 34 | -------------------------------------------------------------------------------- /functions/src/functions/fillLocation/enqueue_task.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { FillLocationSchema } from "./input"; 3 | import { DataResponseSchema, ErrorResponseSchema } from "../../entity/response"; 4 | import { getFunctions } from "firebase-admin/functions"; 5 | import { getFunctionURL } from "../../utils/firebase/gcp"; 6 | import { onFlow } from "@genkit-ai/firebase/functions"; 7 | import { genkitAI } from "../../utils/ai/ai"; 8 | import { appAuthPolicy } from "../../utils/ai/authPolicy"; 9 | import { enqueueFillTODOLocations } from "../fillTODOLocation/enqueue_task"; 10 | 11 | const ResponseSchema = z.union([ 12 | DataResponseSchema.extend({ 13 | data: z.object({ 14 | taskID: z.string(), 15 | }), 16 | }), 17 | ErrorResponseSchema, 18 | ]); 19 | 20 | export const enqueueFillLocation = onFlow( 21 | genkitAI, 22 | { 23 | name: "enqueueFillLocation", 24 | inputSchema: FillLocationSchema, 25 | outputSchema: ResponseSchema, 26 | authPolicy: appAuthPolicy("enqueueFillLocation"), 27 | }, 28 | async (input) => { 29 | const queue = getFunctions().taskQueue("executeFillLocation"); 30 | const executeFillLocationURL = await getFunctionURL("executeFillLocation"); 31 | await queue.enqueue(input, { 32 | uri: executeFillLocationURL, 33 | headers: { 34 | "Content-Type": "application/json", 35 | }, 36 | }); 37 | 38 | await enqueueFillTODOLocations(input); 39 | 40 | const response: z.infer = { 41 | result: "OK", 42 | statusCode: 200, 43 | data: { 44 | taskID: input.taskID, 45 | }, 46 | }; 47 | 48 | return response; 49 | } 50 | ); 51 | -------------------------------------------------------------------------------- /functions/src/functions/fillLocation/execute_task.ts: -------------------------------------------------------------------------------- 1 | import * as functions from "firebase-functions"; 2 | import { z } from "zod"; 3 | import { onTaskDispatched } from "firebase-functions/tasks"; 4 | import { fillTaskLocation } from "./flow"; 5 | import { FillLocationSchema } from "./input"; 6 | import { errorMessage } from "../../utils/error/message"; 7 | import { TaskRetryError } from "../../utils/error/taskRetry"; 8 | 9 | export const executeFillLocation = onTaskDispatched( 10 | { 11 | retryConfig: { 12 | maxAttempts: 10, 13 | minBackoffSeconds: 60, 14 | }, 15 | rateLimits: { 16 | maxConcurrentDispatches: 3, 17 | }, 18 | timeoutSeconds: 10 * 60, 19 | memory: "1GiB", 20 | }, 21 | async (req) => { 22 | console.log("#executeFillTaskLocation"); 23 | try { 24 | const input = req.data as z.infer; 25 | const fillTaskLocationResponse = await fillTaskLocation(input); 26 | console.log({ fillTaskLocationResponse }); 27 | } catch (err) { 28 | functions.logger.error(errorMessage(err)); 29 | if (err instanceof TaskRetryError) { 30 | throw err; 31 | } 32 | } 33 | } 34 | ); 35 | -------------------------------------------------------------------------------- /functions/src/functions/fillLocation/flow.ts: -------------------------------------------------------------------------------- 1 | import { z } from "genkit"; 2 | import { genkitAI } from "../../utils/ai/ai"; 3 | import { database } from "../../utils/firebase/firebase"; 4 | import { 5 | DataResponseSchema, 6 | errorResponse, 7 | ErrorResponseSchema, 8 | } from "../../entity/response"; 9 | import { GroundingDataSchema } from "../../entity/grounding"; 10 | import { zodTypeGuard } from "../../utils/stdlib/type_guard"; 11 | import { FillLocationSchema } from "./input"; 12 | import { TaskPreparedSchema } from "../../entity/task"; 13 | import { LocationSchema } from "../../entity/location"; 14 | import { queryLocation } from "../../utils/queryLocation"; 15 | import { TaskRetryError } from "../../utils/error/taskRetry"; 16 | import { errorMessage } from "../../utils/error/message"; 17 | 18 | const TaskResponseSchema = TaskPreparedSchema.extend({ 19 | locations: z.array(LocationSchema), 20 | locationsAITextResponse: z.string(), 21 | locationsGroundings: z.array(GroundingDataSchema), 22 | }); 23 | const ResponseSchema = z.union([ 24 | DataResponseSchema.extend({ 25 | data: z.object({ 26 | task: TaskResponseSchema, 27 | }), 28 | }), 29 | ErrorResponseSchema, 30 | ]); 31 | 32 | // このflowはCloud Taskから使用されるのでエラーハンドリングは慎重に 33 | export const fillTaskLocation = genkitAI.defineFlow( 34 | { 35 | name: "fillTaskLocation", 36 | inputSchema: FillLocationSchema, 37 | outputSchema: ResponseSchema, 38 | }, 39 | async (input) => { 40 | console.log(`#fillTaskLocation: ${JSON.stringify({ input }, null, 2)}`); 41 | 42 | try { 43 | const { 44 | taskID, 45 | userLocation, 46 | userRequest: { userID }, 47 | } = input; 48 | 49 | const taskDocRef = database 50 | .collection(`/users/${userID}/tasks`) 51 | .doc(taskID); 52 | const taskSnapshot = await taskDocRef.get(); 53 | const task = taskSnapshot.data(); 54 | if (!zodTypeGuard(TaskPreparedSchema, task)) { 55 | return errorResponse( 56 | new Error(`task prepared parse error. taskPrepared: ${task}`) 57 | ); 58 | } 59 | 60 | // e.g) 杉並区成田東4丁目周辺 確定申告に関しての問い合わせ先を教えてください「申告方法の選択: e-Taxを利用するか、郵送または税務署への持参するか決定します 61 | const { aiTextResponse, groundings, locations } = await queryLocation({ 62 | query: `${userLocation.name}周辺 ${task.question}に関しての問い合わせ先を教えてください`, 63 | }).catch((err) => { 64 | throw new TaskRetryError(`queryLocation failed. ${errorMessage(err)}`); 65 | }); 66 | 67 | const taskUpdate: z.infer = { 68 | ...task, 69 | locations: locations, 70 | locationsAITextResponse: aiTextResponse, 71 | locationsGroundings: groundings, 72 | }; 73 | 74 | await taskDocRef.set(taskUpdate, { merge: true }); 75 | 76 | const response: z.infer = { 77 | result: "OK", 78 | statusCode: 200, 79 | data: { 80 | task: taskUpdate, 81 | }, 82 | }; 83 | 84 | return response; 85 | } catch (error) { 86 | if (error instanceof TaskRetryError) { 87 | throw error; 88 | } 89 | return errorResponse(error); 90 | } 91 | } 92 | ); 93 | -------------------------------------------------------------------------------- /functions/src/functions/fillLocation/input.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { UserRequestSchema } from "../../entity/userRequest"; 3 | 4 | export const FillLocationSchema = z.object({ 5 | taskID: z.string(), 6 | userLocation: z.object({ 7 | name: z.string(), 8 | }), 9 | userRequest: UserRequestSchema, 10 | }); 11 | -------------------------------------------------------------------------------- /functions/src/functions/fillTODOLocation/enqueue_task.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { FillTODOLocationSchema } from "./input"; 3 | import { DataResponseSchema, ErrorResponseSchema } from "../../entity/response"; 4 | import { getFunctions } from "firebase-admin/functions"; 5 | import { getFunctionURL } from "../../utils/firebase/gcp"; 6 | import { onFlow } from "@genkit-ai/firebase/functions"; 7 | import { genkitAI } from "../../utils/ai/ai"; 8 | import { appAuthPolicy } from "../../utils/ai/authPolicy"; 9 | import { database } from "../../utils/firebase/firebase"; 10 | import { TODOSchema } from "../../entity/todo"; 11 | import { zodTypeGuard } from "../../utils/stdlib/type_guard"; 12 | import { FillLocationSchema } from "../fillLocation/input"; 13 | 14 | const ResponseSchema = z.union([ 15 | DataResponseSchema.extend({ 16 | data: z.object({ 17 | taskID: z.string(), 18 | }), 19 | }), 20 | ErrorResponseSchema, 21 | ]); 22 | 23 | export const enqueueFillTODOLocation = onFlow( 24 | genkitAI, 25 | { 26 | name: "enqueueFillTODOLocation", 27 | inputSchema: FillTODOLocationSchema, 28 | outputSchema: ResponseSchema, 29 | authPolicy: appAuthPolicy("enqueueFillTODOLocation"), 30 | }, 31 | async (input) => { 32 | const queue = getFunctions().taskQueue("executeFillTODOLocation"); 33 | const executeFillTODOLocationURL = await getFunctionURL( 34 | "executeFillTODOLocation" 35 | ); 36 | await queue.enqueue(input, { 37 | uri: executeFillTODOLocationURL, 38 | headers: { 39 | "Content-Type": "application/json", 40 | }, 41 | }); 42 | 43 | const response: z.infer = { 44 | result: "OK", 45 | statusCode: 200, 46 | data: { 47 | taskID: input.taskID, 48 | }, 49 | }; 50 | 51 | return response; 52 | } 53 | ); 54 | 55 | export const enqueueFillTODOLocations = genkitAI.defineTool( 56 | { 57 | name: "enqueueFillTODOLocations", 58 | description: "enqueueFillTODOLocations", 59 | inputSchema: FillLocationSchema, 60 | outputSchema: ResponseSchema, 61 | }, 62 | async (input) => { 63 | const queue = getFunctions().taskQueue("executeFillTODOLocation"); 64 | const executeFillTODOLocationURL = await getFunctionURL( 65 | "executeFillTODOLocation" 66 | ); 67 | 68 | const todosCollectionRef = database.collection( 69 | `/users/${input.userRequest.userID}/tasks/${input.taskID}/todos` 70 | ); 71 | const todoDocRefs = await todosCollectionRef.listDocuments(); 72 | for (const todoDocRef of todoDocRefs) { 73 | const todoDoc = await todoDocRef.get(); 74 | const todo = todoDoc.data(); 75 | if (!zodTypeGuard(TODOSchema, todo)) { 76 | continue; 77 | } 78 | 79 | const taskInput: z.infer = { 80 | ...input, 81 | todoID: todo.id, 82 | }; 83 | await queue.enqueue(taskInput, { 84 | uri: executeFillTODOLocationURL, 85 | headers: { 86 | "Content-Type": "application/json", 87 | }, 88 | }); 89 | } 90 | 91 | const response: z.infer = { 92 | result: "OK", 93 | statusCode: 200, 94 | data: { 95 | taskID: input.taskID, 96 | }, 97 | }; 98 | 99 | return response; 100 | } 101 | ); 102 | -------------------------------------------------------------------------------- /functions/src/functions/fillTODOLocation/execute_task.ts: -------------------------------------------------------------------------------- 1 | import * as functions from "firebase-functions"; 2 | import { z } from "zod"; 3 | import { onTaskDispatched } from "firebase-functions/tasks"; 4 | import { errorMessage } from "../../utils/error/message"; 5 | import { FillTODOLocationSchema } from "../fillTODOLocation/input"; 6 | import { fillTODOLocation } from "../fillTODOLocation/flow"; 7 | import { TaskRetryError } from "../../utils/error/taskRetry"; 8 | 9 | export const executeFillTODOLocation = onTaskDispatched( 10 | { 11 | retryConfig: { 12 | maxAttempts: 10, 13 | minBackoffSeconds: 60, 14 | }, 15 | rateLimits: { 16 | maxConcurrentDispatches: 3, 17 | }, 18 | timeoutSeconds: 10 * 60, 19 | memory: "1GiB", 20 | }, 21 | async (req) => { 22 | console.log("#executeFillTODOLocation"); 23 | try { 24 | const input = req.data as z.infer; 25 | const fillTODOLocationResponse = await fillTODOLocation(input); 26 | console.log({ fillTODOLocationResponse }); 27 | } catch (err) { 28 | functions.logger.error(errorMessage(err)); 29 | if (err instanceof TaskRetryError) { 30 | throw err; 31 | } 32 | } 33 | } 34 | ); 35 | -------------------------------------------------------------------------------- /functions/src/functions/fillTODOLocation/flow.ts: -------------------------------------------------------------------------------- 1 | import { z } from "genkit"; 2 | import { genkitAI } from "../../utils/ai/ai"; 3 | import { TODOSchema } from "../../entity/todo"; 4 | import { database } from "../../utils/firebase/firebase"; 5 | import { 6 | DataResponseSchema, 7 | errorResponse, 8 | ErrorResponseSchema, 9 | } from "../../entity/response"; 10 | import { GroundingDataSchema } from "../../entity/grounding"; 11 | import { zodTypeGuard } from "../../utils/stdlib/type_guard"; 12 | import { TaskPreparedSchema } from "../../entity/task"; 13 | import { LocationSchema } from "../../entity/location"; 14 | import { queryLocation } from "../../utils/queryLocation"; 15 | import { FillTODOLocationSchema } from "./input"; 16 | import { TaskRetryError } from "../../utils/error/taskRetry"; 17 | import { errorMessage } from "../../utils/error/message"; 18 | 19 | const TODOResponseSchema = TODOSchema.extend({ 20 | locations: z.array(LocationSchema), 21 | locationsAITextResponse: z.string(), 22 | locationsGroundings: z.array(GroundingDataSchema), 23 | }); 24 | const ResponseSchema = z.union([ 25 | DataResponseSchema.extend({ 26 | data: z.object({ 27 | todo: TODOResponseSchema, 28 | }), 29 | }), 30 | ErrorResponseSchema, 31 | ]); 32 | 33 | // このflowはCloud Taskから使用されるのでエラーハンドリングは慎重に 34 | export const fillTODOLocation = genkitAI.defineFlow( 35 | { 36 | name: "fillTODOLocation", 37 | inputSchema: FillTODOLocationSchema, 38 | outputSchema: ResponseSchema, 39 | }, 40 | async (input) => { 41 | console.log(`#fillTODOLocation: ${JSON.stringify({ input }, null, 2)}`); 42 | try { 43 | const { 44 | taskID, 45 | todoID, 46 | userLocation, 47 | userRequest: { userID }, 48 | } = input; 49 | 50 | const taskDocRef = database 51 | .collection(`/users/${userID}/tasks`) 52 | .doc(taskID); 53 | const taskSnapshot = await taskDocRef.get(); 54 | const task = taskSnapshot.data(); 55 | if (!zodTypeGuard(TaskPreparedSchema, task)) { 56 | return errorResponse( 57 | new Error(`task prepared parse error. taskPrepared: ${task}`) 58 | ); 59 | } 60 | 61 | const todoDocRef = await database.doc( 62 | `/users/${userID}/tasks/${taskID}/todos/${todoID}` 63 | ); 64 | const todoDocSnapshot = await todoDocRef.get(); 65 | const todo = todoDocSnapshot.data(); 66 | if (!zodTypeGuard(TODOSchema, todo)) { 67 | return errorResponse( 68 | new Error(`todo loading parse error. todoLoading: ${todo}`) 69 | ); 70 | } 71 | // e.g) 杉並区成田東4丁目周辺 確定申告に関して、次の文章に対する問い合わせ先を教えてください「申告方法の選択: e-Taxを利用するか、郵送または税務署への持参するか決定します」 72 | const { aiTextResponse, groundings, locations } = await queryLocation({ 73 | query: `${userLocation.name}周辺 ${task.question}に関して、次の文章に対する問い合わせ先を教えてください「${todo.content}: ${todo.supplement ?? ""}」`, 74 | }).catch((err) => { 75 | throw new TaskRetryError(`queryLocation failed. ${errorMessage(err)}`); 76 | }); 77 | 78 | const todoUpdate: z.infer = { 79 | ...todo, 80 | locations: locations, 81 | locationsAITextResponse: aiTextResponse, 82 | locationsGroundings: groundings, 83 | }; 84 | await todoDocRef.set(todoUpdate, { merge: true }); 85 | 86 | const response: z.infer = { 87 | result: "OK", 88 | statusCode: 200, 89 | data: { 90 | todo: todoUpdate, 91 | }, 92 | }; 93 | 94 | return response; 95 | } catch (error) { 96 | if (error instanceof TaskRetryError) { 97 | throw error; 98 | } 99 | return errorResponse(error); 100 | } 101 | } 102 | ); 103 | -------------------------------------------------------------------------------- /functions/src/functions/fillTODOLocation/input.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { UserRequestSchema } from "../../entity/userRequest"; 3 | 4 | export const FillTODOLocationSchema = z.object({ 5 | taskID: z.string(), 6 | todoID: z.string(), 7 | userLocation: z.object({ 8 | name: z.string(), 9 | }), 10 | userRequest: UserRequestSchema, 11 | }); 12 | -------------------------------------------------------------------------------- /functions/src/functions/taskCreate/enqueue_task.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { TaskCreateSchema } from "./input"; 3 | import { DataResponseSchema, ErrorResponseSchema } from "../../entity/response"; 4 | import { getFunctions } from "firebase-admin/functions"; 5 | import { getFunctionURL } from "../../utils/firebase/gcp"; 6 | import { onFlow } from "@genkit-ai/firebase/functions"; 7 | import { genkitAI } from "../../utils/ai/ai"; 8 | import { appAuthPolicy } from "../../utils/ai/authPolicy"; 9 | 10 | const ResponseSchema = z.union([ 11 | DataResponseSchema.extend({ 12 | data: z.object({ 13 | taskID: z.string(), 14 | }), 15 | }), 16 | ErrorResponseSchema, 17 | ]); 18 | 19 | export const enqueueTaskCreate = onFlow( 20 | genkitAI, 21 | { 22 | name: "enqueueTaskCreate", 23 | inputSchema: TaskCreateSchema.pick({ 24 | taskID: true, 25 | question: true, 26 | userRequest: true, 27 | }), 28 | outputSchema: ResponseSchema, 29 | authPolicy: appAuthPolicy("enqueueTaskCreate"), 30 | }, 31 | async (input) => { 32 | const queue = getFunctions().taskQueue("executeTaskCreate"); 33 | const executeTaskCreateURL = await getFunctionURL("executeTaskCreate"); 34 | await queue.enqueue(input, { 35 | uri: executeTaskCreateURL, 36 | headers: { 37 | "Content-Type": "application/json", 38 | }, 39 | }); 40 | 41 | const response: z.infer = { 42 | result: "OK", 43 | statusCode: 200, 44 | data: { 45 | taskID: input.taskID, 46 | }, 47 | }; 48 | 49 | return response; 50 | } 51 | ); 52 | -------------------------------------------------------------------------------- /functions/src/functions/taskCreate/execute_task.ts: -------------------------------------------------------------------------------- 1 | import * as functions from "firebase-functions"; 2 | import { z } from "zod"; 3 | import { onTaskDispatched } from "firebase-functions/tasks"; 4 | import { taskCreate } from "./flow"; 5 | import { TaskCreateSchema } from "./input"; 6 | import { errorMessage } from "../../utils/error/message"; 7 | import { TaskRetryError } from "../../utils/error/taskRetry"; 8 | 9 | export const executeTaskCreate = onTaskDispatched( 10 | { 11 | retryConfig: { 12 | maxAttempts: 10, 13 | minBackoffSeconds: 60, 14 | }, 15 | rateLimits: { 16 | maxConcurrentDispatches: 3, 17 | }, 18 | timeoutSeconds: 10 * 60, 19 | memory: "1GiB", 20 | }, 21 | async (req) => { 22 | console.log("#executeTaskCreate"); 23 | try { 24 | const input = req.data as z.infer; 25 | const response = await taskCreate(input); 26 | console.log(response); 27 | } catch (err) { 28 | functions.logger.error(errorMessage(err)); 29 | if (err instanceof TaskRetryError) { 30 | throw err; 31 | } 32 | } 33 | } 34 | ); 35 | -------------------------------------------------------------------------------- /functions/src/functions/taskCreate/input.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { UserRequestSchema } from "../../entity/userRequest"; 3 | 4 | export const TaskCreateSchema = z.object({ 5 | taskID: z.string(), 6 | question: z.string(), 7 | userRequest: UserRequestSchema, 8 | }); 9 | -------------------------------------------------------------------------------- /functions/src/functions/todoPrepare/enqueue_task.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { DataResponseSchema, ErrorResponseSchema } from "../../entity/response"; 3 | import { getFunctions } from "firebase-admin/functions"; 4 | import { getFunctionURL } from "../../utils/firebase/gcp"; 5 | import { genkitAI } from "../../utils/ai/ai"; 6 | import { TODOPrepareSchema } from "./input"; 7 | 8 | const ResponseSchema = z.union([ 9 | DataResponseSchema.extend({ 10 | data: z.object({ 11 | taskID: z.string(), 12 | }), 13 | }), 14 | ErrorResponseSchema, 15 | ]); 16 | 17 | export const enqueueTODOPrepare = genkitAI.defineTool( 18 | { 19 | name: "enqueueTODOPrepare", 20 | description: "enqueueTODOPrepare", 21 | inputSchema: TODOPrepareSchema, 22 | outputSchema: ResponseSchema, 23 | }, 24 | async (input) => { 25 | const queue = getFunctions().taskQueue("executeTODOPrepare"); 26 | const executeTODOPrepareURL = await getFunctionURL("executeTODOPrepare"); 27 | await queue.enqueue(input, { 28 | uri: executeTODOPrepareURL, 29 | headers: { 30 | "Content-Type": "application/json", 31 | }, 32 | }); 33 | 34 | const response: z.infer = { 35 | result: "OK", 36 | statusCode: 200, 37 | data: { 38 | taskID: input.taskID, 39 | }, 40 | }; 41 | 42 | return response; 43 | } 44 | ); 45 | -------------------------------------------------------------------------------- /functions/src/functions/todoPrepare/execute_task.ts: -------------------------------------------------------------------------------- 1 | import * as functions from "firebase-functions"; 2 | import { z } from "zod"; 3 | import { onTaskDispatched } from "firebase-functions/tasks"; 4 | import { TODOPrepareSchema } from "./input"; 5 | import { errorMessage } from "../../utils/error/message"; 6 | import { todoPrepare } from "./flow"; 7 | import { TaskRetryError } from "../../utils/error/taskRetry"; 8 | 9 | export const executeTODOPrepare = onTaskDispatched( 10 | { 11 | retryConfig: { 12 | // TODO: 提出する時に50とかにしよう 13 | maxAttempts: 10, 14 | minBackoffSeconds: 60, 15 | }, 16 | rateLimits: { 17 | maxConcurrentDispatches: 3, 18 | }, 19 | timeoutSeconds: 10 * 60, 20 | memory: "1GiB", 21 | }, 22 | async (req) => { 23 | console.log("#executeTODOPrepare"); 24 | try { 25 | const todoInput = req.data as z.infer; 26 | const response = await todoPrepare(todoInput); 27 | console.log(response); 28 | } catch (err) { 29 | functions.logger.error(errorMessage(err)); 30 | if (err instanceof TaskRetryError) { 31 | throw err; 32 | } 33 | } 34 | } 35 | ); 36 | -------------------------------------------------------------------------------- /functions/src/functions/todoPrepare/input.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { UserRequestSchema } from "../../entity/userRequest"; 3 | 4 | export const TODOPrepareSchema = z.object({ 5 | taskID: z.string(), 6 | todoID: z.string(), 7 | question: z.string(), 8 | taskTopic: z.string(), 9 | content: z.string(), 10 | supplement: z.string(), 11 | userRequest: UserRequestSchema, 12 | }); 13 | -------------------------------------------------------------------------------- /functions/src/index.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | 3 | import { initializeApp } from "firebase-admin/app"; 4 | 5 | initializeApp({ 6 | serviceAccountId: 7 | process.env.GOOGLE_APPLICATION_CREDENTIALS_SERVICE_ACCOUNT_ID, 8 | }); 9 | 10 | export const enqueueTaskCreate = 11 | require("./functions/taskCreate/enqueue_task").enqueueTaskCreate; 12 | export const executeTaskCreate = 13 | require("./functions/taskCreate/execute_task").executeTaskCreate; 14 | 15 | export const enqueueTODOPrepare = 16 | require("./functions/todoPrepare/enqueue_task").enqueueTODOPrepare; 17 | export const executeTODOPrepare = 18 | require("./functions/todoPrepare/execute_task").executeTODOPrepare; 19 | 20 | export const enqueueFillLocation = 21 | require("./functions/fillLocation/enqueue_task").enqueueFillLocation; 22 | export const executeFillLocation = 23 | require("./functions/fillLocation/execute_task").executeFillLocation; 24 | 25 | export const enqueueFillTODOLocation = 26 | require("./functions/fillTODOLocation/enqueue_task").enqueueFillTODOLocation; 27 | export const executeFillTODOLocation = 28 | require("./functions/fillTODOLocation/execute_task").executeFillTODOLocation; 29 | -------------------------------------------------------------------------------- /functions/src/utils/ai/authPolicy.ts: -------------------------------------------------------------------------------- 1 | import { firebaseAuth } from "@genkit-ai/firebase/auth"; 2 | import { FunctionFlowAuth, noAuth } from "@genkit-ai/firebase/functions"; 3 | import * as z from "zod"; 4 | 5 | export function appAuthPolicy( 6 | fn: string 7 | ): FunctionFlowAuth { 8 | if (process.env.ENV === "local") { 9 | return noAuth(); 10 | } 11 | // 他のonFlowからrunFlow経由で呼び出す場合は、FUNCTION_NAMEが一致しないため、noAuthを返す 12 | if (process.env.FUNCTION_NAME !== fn) { 13 | return noAuth(); 14 | } 15 | return firebaseAuth((user, input) => { 16 | if (user.exp * 1000 < Date.now()) { 17 | throw new Error("User token is expired"); 18 | } 19 | 20 | if (process.env.APP_ENV === "dev") { 21 | console.log( 22 | "[DEBUG] User ID: ", 23 | user.uid, 24 | "Input: ", 25 | JSON.stringify(input) 26 | ); 27 | } 28 | if ( 29 | input.userRequest?.userID != null && 30 | input.userRequest.userID !== user.uid 31 | ) { 32 | throw new Error("User ID is not matched"); 33 | } 34 | }); 35 | } 36 | -------------------------------------------------------------------------------- /functions/src/utils/error/message.ts: -------------------------------------------------------------------------------- 1 | export function errorMessage(error: unknown): string { 2 | if (error instanceof Error) { 3 | return error.message; 4 | } else if (typeof error === "string") { 5 | return error; 6 | } else { 7 | return "An unknown error occurred"; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /functions/src/utils/error/taskRetry.ts: -------------------------------------------------------------------------------- 1 | export class TaskRetryError extends Error { 2 | constructor(message: string) { 3 | super(message); 4 | this.name = "TaskRetryError"; 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /functions/src/utils/firebase/converter.ts: -------------------------------------------------------------------------------- 1 | import * as admin from "firebase-admin"; 2 | import { z } from "zod"; 3 | 4 | // Usage: 5 | /* 6 | const relatedArticlesCollection = await firestore() 7 | .collectionGroup("articles") 8 | .where( 9 | "symbolicKeywords", 10 | "array-contains-any", 11 | article.symbolicKeywords 12 | ) 13 | .withConverter(firestoreConverter(ArticleSchema)) 14 | .get(); 15 | */ 16 | export const firestoreConverter = ( 17 | schema: T 18 | ): admin.firestore.FirestoreDataConverter> => ({ 19 | toFirestore: (data: z.infer): admin.firestore.DocumentData => { 20 | return schema.parse(data); 21 | }, 22 | fromFirestore: ( 23 | snapshot: admin.firestore.QueryDocumentSnapshot> 24 | ): z.infer => { 25 | return schema.parse(snapshot.data()); 26 | }, 27 | }); 28 | -------------------------------------------------------------------------------- /functions/src/utils/firebase/firebase.ts: -------------------------------------------------------------------------------- 1 | import admin = require("firebase-admin"); 2 | 3 | export const database = admin.firestore(); 4 | database.settings({ ignoreUndefinedProperties: true }); 5 | 6 | export const auth = admin.auth(); 7 | -------------------------------------------------------------------------------- /functions/src/utils/firebase/gcp.ts: -------------------------------------------------------------------------------- 1 | import * as functions from "firebase-functions"; 2 | import { GoogleAuth } from "google-auth-library"; 3 | 4 | // ref: https://firebase.google.com/docs/functions/task-functions?gen=2nd 5 | export async function getFunctionURL(name: string): Promise { 6 | const auth = new GoogleAuth({ 7 | scopes: "https://www.googleapis.com/auth/cloud-platform", 8 | }); 9 | const projectId = await auth.getProjectId(); 10 | 11 | // NOTE: [CloudTask:Region] "asia-northeast1" を使いたいが、なぜか us-central1 じゃないと動かないため明示的に指定する 12 | const location = "us-central1"; 13 | const url = 14 | "https://cloudfunctions.googleapis.com/v2/" + 15 | `projects/${projectId}/locations/${location}/functions/${name}`; 16 | 17 | functions.logger.log(`Getting function URL for ${name} at ${url}`); 18 | 19 | const client = await auth.getClient(); 20 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 21 | const res = await client.request({ url }); 22 | const uri = res.data?.serviceConfig?.uri; 23 | if (!uri) { 24 | throw new Error(`Unable to retreive uri for function at ${url}`); 25 | } 26 | return uri; 27 | } 28 | -------------------------------------------------------------------------------- /functions/src/utils/queryLocation.ts: -------------------------------------------------------------------------------- 1 | import { z } from "genkit"; 2 | import { genkitAI } from "./ai/ai"; 3 | import { GroundingData, GroundingDataSchema } from "../entity/grounding"; 4 | import { LocationSchema } from "../entity/location"; 5 | import { GroundingChunk } from "@google/generative-ai"; 6 | 7 | export const queryLocation = genkitAI.defineFlow( 8 | { 9 | name: "queryLocation", 10 | inputSchema: z.object({ 11 | query: z.string(), 12 | }), 13 | outputSchema: z.object({ 14 | aiTextResponse: z.string(), 15 | groundings: z.array(GroundingDataSchema), 16 | locations: z.array(LocationSchema), 17 | }), 18 | }, 19 | async (input) => { 20 | console.log(`#queryLocation: ${JSON.stringify({ input }, null, 2)}`); 21 | const response = await genkitAI.generate({ 22 | prompt: 23 | input.query + 24 | "\n" + 25 | "場所の名称,郵便番号,住所(郵便番号を除く),メールアドレス,電話番号。これらの情報を提供してください。見つからない場合はその旨も書いてください", 26 | }); 27 | const aiTextResponse = response.text ?? ""; 28 | const responseCustom = response.custom as any; 29 | const candidates = responseCustom.candidates; 30 | const groundings: GroundingData[] = []; 31 | if (candidates) { 32 | for (const candidate of candidates) { 33 | const groudingMetadata = candidate?.groundingMetadata; 34 | const index = candidate?.index; 35 | const anyGroudingMetadata = groudingMetadata as any; 36 | // typo: https://github.com/google-gemini/generative-ai-js/issues/323 37 | const groundingChunks = anyGroudingMetadata[ 38 | "groundingChunks" 39 | ] as GroundingChunk[]; 40 | const web = groundingChunks?.[0].web; 41 | const title = web?.title; 42 | const url = web?.uri; 43 | groundings.push({ title, url, index }); 44 | } 45 | } 46 | 47 | const jsonResponse = await genkitAI.generate({ 48 | prompt: ` 49 | 次の文章から場所に関する情報を取り出してください 50 | ---- 51 | ${aiTextResponse} 52 | ---- 53 | `, 54 | output: { 55 | schema: z.array(LocationSchema), 56 | }, 57 | }); 58 | 59 | const locations = jsonResponse.output ?? []; 60 | 61 | return { 62 | aiTextResponse, 63 | groundings, 64 | locations, 65 | }; 66 | } 67 | ); 68 | -------------------------------------------------------------------------------- /functions/src/utils/stdlib/nullable.ts: -------------------------------------------------------------------------------- 1 | import * as z from "zod"; 2 | 3 | // NOTE: .partial() だと undefined にしかならない 4 | export function nullable(schema: TSchema) { 5 | const entries = Object.entries(schema.shape) as [ 6 | keyof TSchema["shape"], 7 | z.ZodTypeAny, 8 | ][]; 9 | 10 | const newProps = entries.reduce( 11 | (acc, [key, value]) => { 12 | acc[key] = value.nullable(); 13 | return acc; 14 | }, 15 | {} as { 16 | [key in keyof TSchema["shape"]]: z.ZodNullable; 17 | } 18 | ); 19 | 20 | return z.object(newProps); 21 | } 22 | 23 | export const nonNullable = (value: T): value is NonNullable => 24 | value != null; 25 | -------------------------------------------------------------------------------- /functions/src/utils/stdlib/type_guard.ts: -------------------------------------------------------------------------------- 1 | import type z from "zod"; 2 | 3 | // 実際に遭遇したケース。safeParseでは、指定した型の新しいparseされたインスタンスが返却されるのでtype guardを用意する 4 | // 5 | // export const TaskPreparedSchema = z 6 | // .object({ 7 | // status: z.literal("prepared"), 8 | // preparedDateTime: FirestoreTimestampSchema, 9 | // }) 10 | // ..... 11 | // const updateTask: z.infer = { 12 | // status: "prepared", 13 | // preparedDateTime: Timestamp.now(), 14 | // serverUpdatedDateTime: Timestamp.now(), 15 | // }; 16 | 17 | // const taskSnapshot = await database 18 | // .doc(`/users/${userID}/tasks/${taskID}`) 19 | // .get(); 20 | // const task = TaskPreparedSchema.safeParse({ 21 | // ...(taskSnapshot.data() ?? {}), 22 | // ...updateTask, 23 | // }).data; 24 | // if (!task) { 25 | // // 用意できなかったプロパティがあると判断するのでRetryする 26 | // functions.logger.info("task not fullfilled", { 27 | // taskLoading, 28 | // }); 29 | // throw new Error("task not fullfilled"); 30 | // } 31 | 32 | export function zodTypeGuard( 33 | schema: T, 34 | data: unknown 35 | ): data is z.infer { 36 | const result = schema.safeParse(data); 37 | if (process.env.APP_ENV === "local") { 38 | if (!result.success) { 39 | console.error(result.error); 40 | } 41 | } 42 | return result.success; 43 | } 44 | -------------------------------------------------------------------------------- /functions/tsconfig.dev.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": [ 3 | ".eslintrc.js" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /functions/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": true, 4 | "target": "ES2020", 5 | "module": "commonjs", 6 | "sourceMap": true, 7 | "outDir": "./lib", 8 | "strict": true, 9 | "strictNullChecks": true, 10 | "alwaysStrict": true, 11 | "moduleResolution": "node", 12 | "esModuleInterop": true, 13 | "skipLibCheck": true, 14 | "forceConsistentCasingInFileNames": true 15 | }, 16 | "include": ["src/**/*"] 17 | } 18 | -------------------------------------------------------------------------------- /ios/.gitignore: -------------------------------------------------------------------------------- 1 | **/dgph 2 | *.mode1v3 3 | *.mode2v3 4 | *.moved-aside 5 | *.pbxuser 6 | *.perspectivev3 7 | **/*sync/ 8 | .sconsign.dblite 9 | .tags* 10 | **/.vagrant/ 11 | **/DerivedData/ 12 | Icon? 13 | **/Pods/ 14 | **/.symlinks/ 15 | profile 16 | xcuserdata 17 | **/.generated/ 18 | Flutter/App.framework 19 | Flutter/Flutter.framework 20 | Flutter/Flutter.podspec 21 | Flutter/Generated.xcconfig 22 | Flutter/ephemeral/ 23 | Flutter/app.flx 24 | Flutter/app.zip 25 | Flutter/flutter_assets/ 26 | Flutter/flutter_export_environment.sh 27 | ServiceDefinitions.json 28 | Runner/GeneratedPluginRegistrant.* 29 | 30 | # Exceptions to above rules. 31 | !default.mode1v3 32 | !default.mode2v3 33 | !default.pbxuser 34 | !default.perspectivev3 35 | 36 | build 37 | -------------------------------------------------------------------------------- /ios/Firebase/.gitignore: -------------------------------------------------------------------------------- 1 | 2 | GoogleService-Info.plist 3 | -------------------------------------------------------------------------------- /ios/Flutter/AppFrameworkInfo.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | en 7 | CFBundleExecutable 8 | App 9 | CFBundleIdentifier 10 | io.flutter.flutter.app 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | App 15 | CFBundlePackageType 16 | FMWK 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleSignature 20 | ???? 21 | CFBundleVersion 22 | 1.0 23 | MinimumOSVersion 24 | 12.0 25 | 26 | 27 | -------------------------------------------------------------------------------- /ios/Flutter/Debug.xcconfig: -------------------------------------------------------------------------------- 1 | #include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" 2 | #include "Generated.xcconfig" 3 | -------------------------------------------------------------------------------- /ios/Flutter/Release.xcconfig: -------------------------------------------------------------------------------- 1 | #include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" 2 | #include "Generated.xcconfig" 3 | -------------------------------------------------------------------------------- /ios/Podfile: -------------------------------------------------------------------------------- 1 | # Uncomment this line to define a global platform for your project 2 | platform :ios, '16.0' 3 | 4 | # Source Code: https://github.com/FirebaseExtended/flutterfire/blob/efdcc16aa2f3d45a3835f0eb73801ba88e808e07/packages/firebase_core/firebase_core/ios/firebase_core.podspec#L6-L8 5 | # It should be with https://github.com/invertase/firestore-ios-sdk-frameworks.git :tag 6 | $FirebaseSDKVersion='11.6.0' 7 | 8 | # CocoaPods analytics sends network stats synchronously affecting flutter build latency. 9 | ENV['COCOAPODS_DISABLE_STATS'] = 'true' 10 | 11 | project 'Runner', { 12 | 'Debug' => :debug, 13 | 'Profile' => :release, 14 | 'Release' => :release, 15 | } 16 | 17 | def flutter_root 18 | generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__) 19 | unless File.exist?(generated_xcode_build_settings_path) 20 | raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first" 21 | end 22 | 23 | File.foreach(generated_xcode_build_settings_path) do |line| 24 | matches = line.match(/FLUTTER_ROOT\=(.*)/) 25 | return matches[1].strip if matches 26 | end 27 | raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get" 28 | end 29 | 30 | require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) 31 | 32 | flutter_ios_podfile_setup 33 | 34 | target 'Runner' do 35 | use_frameworks! 36 | use_modular_headers! 37 | 38 | # Ref: https://github.dev/FirebaseExtended/flutterfire/tree/master/packages/firebase_core/firebase_core/ios/firebase_sdk_version.rb#L1-L2 39 | pod 'FirebaseFirestore', :git => 'https://github.com/invertase/firestore-ios-sdk-frameworks.git', :tag => $FirebaseSDKVersion 40 | 41 | flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) 42 | target 'RunnerTests' do 43 | inherit! :search_paths 44 | end 45 | end 46 | 47 | post_install do |installer| 48 | installer.pods_project.targets.each do |target| 49 | flutter_additional_ios_build_settings(target) 50 | 51 | if target.name == 'BoringSSL-GRPC' 52 | target.source_build_phase.files.each do |file| 53 | if file.settings && file.settings['COMPILER_FLAGS'] 54 | flags = file.settings['COMPILER_FLAGS'].split 55 | flags.reject! { |flag| flag == '-GCC_WARN_INHIBIT_ALL_WARNINGS' } 56 | file.settings['COMPILER_FLAGS'] = flags.join(' ') 57 | end 58 | end 59 | end 60 | 61 | if target.name == "geolocator_apple" 62 | target.build_configurations.each do |config| 63 | config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] ||= ['$(inherited)', 'BYPASS_PERMISSION_LOCATION_ALWAYS=1'] 64 | end 65 | end 66 | 67 | target.build_configurations.each do |config| 68 | # Reference: https://github.com/flutter/flutter/issues/64502#issuecomment-759165857 69 | config.build_settings["EXCLUDED_ARCHS[sdk=iphonesimulator*]"] = "arm64 i386" 70 | config.build_settings['ENABLE_BITCODE'] = 'NO' 71 | 72 | # workaround: https://github.com/CocoaPods/CocoaPods/issues/11196#issuecomment-1058716826 73 | if config.build_settings['WRAPPER_EXTENSION'] == 'bundle' 74 | config.build_settings['DEVELOPMENT_TEAM'] = 'TQPN82UBBY' 75 | end 76 | end 77 | end 78 | end 79 | -------------------------------------------------------------------------------- /ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | PreviewsEnabled 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 37 | 38 | 39 | 40 | 43 | 49 | 50 | 51 | 52 | 53 | 63 | 65 | 71 | 72 | 73 | 74 | 80 | 82 | 88 | 89 | 90 | 91 | 93 | 94 | 97 | 98 | 99 | -------------------------------------------------------------------------------- /ios/Runner.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | PreviewsEnabled 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /ios/Runner/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | import Flutter 2 | import UIKit 3 | 4 | @main 5 | @objc class AppDelegate: FlutterAppDelegate { 6 | override func application( 7 | _ application: UIApplication, 8 | didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? 9 | ) -> Bool { 10 | GeneratedPluginRegistrant.register(with: self) 11 | return super.application(application, didFinishLaunchingWithOptions: launchOptions) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "size" : "20x20", 5 | "idiom" : "iphone", 6 | "filename" : "Icon-App-20x20@2x.png", 7 | "scale" : "2x" 8 | }, 9 | { 10 | "size" : "20x20", 11 | "idiom" : "iphone", 12 | "filename" : "Icon-App-20x20@3x.png", 13 | "scale" : "3x" 14 | }, 15 | { 16 | "size" : "29x29", 17 | "idiom" : "iphone", 18 | "filename" : "Icon-App-29x29@1x.png", 19 | "scale" : "1x" 20 | }, 21 | { 22 | "size" : "29x29", 23 | "idiom" : "iphone", 24 | "filename" : "Icon-App-29x29@2x.png", 25 | "scale" : "2x" 26 | }, 27 | { 28 | "size" : "29x29", 29 | "idiom" : "iphone", 30 | "filename" : "Icon-App-29x29@3x.png", 31 | "scale" : "3x" 32 | }, 33 | { 34 | "size" : "40x40", 35 | "idiom" : "iphone", 36 | "filename" : "Icon-App-40x40@2x.png", 37 | "scale" : "2x" 38 | }, 39 | { 40 | "size" : "40x40", 41 | "idiom" : "iphone", 42 | "filename" : "Icon-App-40x40@3x.png", 43 | "scale" : "3x" 44 | }, 45 | { 46 | "size" : "60x60", 47 | "idiom" : "iphone", 48 | "filename" : "Icon-App-60x60@2x.png", 49 | "scale" : "2x" 50 | }, 51 | { 52 | "size" : "60x60", 53 | "idiom" : "iphone", 54 | "filename" : "Icon-App-60x60@3x.png", 55 | "scale" : "3x" 56 | }, 57 | { 58 | "size" : "20x20", 59 | "idiom" : "ipad", 60 | "filename" : "Icon-App-20x20@1x.png", 61 | "scale" : "1x" 62 | }, 63 | { 64 | "size" : "20x20", 65 | "idiom" : "ipad", 66 | "filename" : "Icon-App-20x20@2x.png", 67 | "scale" : "2x" 68 | }, 69 | { 70 | "size" : "29x29", 71 | "idiom" : "ipad", 72 | "filename" : "Icon-App-29x29@1x.png", 73 | "scale" : "1x" 74 | }, 75 | { 76 | "size" : "29x29", 77 | "idiom" : "ipad", 78 | "filename" : "Icon-App-29x29@2x.png", 79 | "scale" : "2x" 80 | }, 81 | { 82 | "size" : "40x40", 83 | "idiom" : "ipad", 84 | "filename" : "Icon-App-40x40@1x.png", 85 | "scale" : "1x" 86 | }, 87 | { 88 | "size" : "40x40", 89 | "idiom" : "ipad", 90 | "filename" : "Icon-App-40x40@2x.png", 91 | "scale" : "2x" 92 | }, 93 | { 94 | "size" : "76x76", 95 | "idiom" : "ipad", 96 | "filename" : "Icon-App-76x76@1x.png", 97 | "scale" : "1x" 98 | }, 99 | { 100 | "size" : "76x76", 101 | "idiom" : "ipad", 102 | "filename" : "Icon-App-76x76@2x.png", 103 | "scale" : "2x" 104 | }, 105 | { 106 | "size" : "83.5x83.5", 107 | "idiom" : "ipad", 108 | "filename" : "Icon-App-83.5x83.5@2x.png", 109 | "scale" : "2x" 110 | }, 111 | { 112 | "size" : "1024x1024", 113 | "idiom" : "ios-marketing", 114 | "filename" : "Icon-App-1024x1024@1x.png", 115 | "scale" : "1x" 116 | } 117 | ], 118 | "info" : { 119 | "version" : 1, 120 | "author" : "xcode" 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bannzai/zenn_ai_hackathon/81d4f33f4337a554ac9792d0ac880c45f573cabf/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bannzai/zenn_ai_hackathon/81d4f33f4337a554ac9792d0ac880c45f573cabf/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bannzai/zenn_ai_hackathon/81d4f33f4337a554ac9792d0ac880c45f573cabf/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bannzai/zenn_ai_hackathon/81d4f33f4337a554ac9792d0ac880c45f573cabf/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bannzai/zenn_ai_hackathon/81d4f33f4337a554ac9792d0ac880c45f573cabf/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bannzai/zenn_ai_hackathon/81d4f33f4337a554ac9792d0ac880c45f573cabf/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bannzai/zenn_ai_hackathon/81d4f33f4337a554ac9792d0ac880c45f573cabf/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bannzai/zenn_ai_hackathon/81d4f33f4337a554ac9792d0ac880c45f573cabf/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bannzai/zenn_ai_hackathon/81d4f33f4337a554ac9792d0ac880c45f573cabf/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bannzai/zenn_ai_hackathon/81d4f33f4337a554ac9792d0ac880c45f573cabf/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bannzai/zenn_ai_hackathon/81d4f33f4337a554ac9792d0ac880c45f573cabf/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bannzai/zenn_ai_hackathon/81d4f33f4337a554ac9792d0ac880c45f573cabf/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bannzai/zenn_ai_hackathon/81d4f33f4337a554ac9792d0ac880c45f573cabf/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bannzai/zenn_ai_hackathon/81d4f33f4337a554ac9792d0ac880c45f573cabf/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bannzai/zenn_ai_hackathon/81d4f33f4337a554ac9792d0ac880c45f573cabf/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "LaunchImage.png", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "filename" : "LaunchImage@2x.png", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "idiom" : "universal", 15 | "filename" : "LaunchImage@3x.png", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "version" : 1, 21 | "author" : "xcode" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bannzai/zenn_ai_hackathon/81d4f33f4337a554ac9792d0ac880c45f573cabf/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bannzai/zenn_ai_hackathon/81d4f33f4337a554ac9792d0ac880c45f573cabf/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bannzai/zenn_ai_hackathon/81d4f33f4337a554ac9792d0ac880c45f573cabf/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md: -------------------------------------------------------------------------------- 1 | # Launch Screen Assets 2 | 3 | You can customize the launch screen with your own desired assets by replacing the image files in this directory. 4 | 5 | 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. -------------------------------------------------------------------------------- /ios/Runner/Base.lproj/LaunchScreen.storyboard: -------------------------------------------------------------------------------- 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 | -------------------------------------------------------------------------------- /ios/Runner/Base.lproj/Main.storyboard: -------------------------------------------------------------------------------- 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 | -------------------------------------------------------------------------------- /ios/Runner/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleDisplayName 8 | Zenn Ai Hackathon 9 | CFBundleExecutable 10 | $(EXECUTABLE_NAME) 11 | CFBundleIdentifier 12 | $(PRODUCT_BUNDLE_IDENTIFIER) 13 | CFBundleInfoDictionaryVersion 14 | 6.0 15 | CFBundleName 16 | zenn_ai_hackathon 17 | CFBundlePackageType 18 | APPL 19 | CFBundleShortVersionString 20 | $(FLUTTER_BUILD_NAME) 21 | CFBundleSignature 22 | ???? 23 | CFBundleVersion 24 | $(FLUTTER_BUILD_NUMBER) 25 | LSRequiresIPhoneOS 26 | 27 | UILaunchStoryboardName 28 | LaunchScreen 29 | UIMainStoryboardFile 30 | Main 31 | UISupportedInterfaceOrientations 32 | 33 | UIInterfaceOrientationPortrait 34 | UIInterfaceOrientationLandscapeLeft 35 | UIInterfaceOrientationLandscapeRight 36 | 37 | UISupportedInterfaceOrientations~ipad 38 | 39 | UIInterfaceOrientationPortrait 40 | UIInterfaceOrientationPortraitUpsideDown 41 | UIInterfaceOrientationLandscapeLeft 42 | UIInterfaceOrientationLandscapeRight 43 | 44 | CADisableMinimumFrameDurationOnPhone 45 | 46 | UIApplicationSupportsIndirectInputEvents 47 | 48 | NSLocationWhenInUseUsageDescription 49 | This app needs access to location when open. 50 | NSCalendarsUsageDescription 51 | カレンダーの予定の取得・書き込みにカレンダーを使用します 52 | NSContactsUsageDescription 53 | カレンダーのイベントへアクセスします 54 | NSCalendarsFullAccessUsageDescription 55 | カレンダーの予定の取得・書き込みにカレンダーを使用します 56 | 57 | 58 | -------------------------------------------------------------------------------- /ios/Runner/Runner-Bridging-Header.h: -------------------------------------------------------------------------------- 1 | #import "GeneratedPluginRegistrant.h" 2 | -------------------------------------------------------------------------------- /ios/RunnerTests/RunnerTests.swift: -------------------------------------------------------------------------------- 1 | import Flutter 2 | import UIKit 3 | import XCTest 4 | 5 | class RunnerTests: XCTestCase { 6 | 7 | func testExample() { 8 | // If you add code to the Runner application, consider adding tests here. 9 | // See https://developer.apple.com/documentation/xctest for more information about using XCTest. 10 | } 11 | 12 | } 13 | -------------------------------------------------------------------------------- /lib/app.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:todomaker/features/root/page.dart'; 3 | import 'package:todomaker/style/color.dart'; 4 | 5 | class App extends StatelessWidget { 6 | const App({super.key}); 7 | 8 | // This widget is the root of your application. 9 | @override 10 | Widget build(BuildContext context) { 11 | final colorScheme = ColorScheme.fromSeed( 12 | seedColor: AppColors.primary, 13 | primary: AppColors.primary, 14 | ); 15 | 16 | return MaterialApp( 17 | theme: ThemeData( 18 | colorScheme: colorScheme, 19 | dividerColor: Colors.black, 20 | bottomSheetTheme: const BottomSheetThemeData( 21 | backgroundColor: AppColors.formBackground, 22 | ), 23 | textSelectionTheme: const TextSelectionThemeData( 24 | cursorColor: AppColors.primary, 25 | ), 26 | inputDecorationTheme: InputDecorationTheme( 27 | filled: true, 28 | fillColor: Colors.white, 29 | enabledBorder: OutlineInputBorder( 30 | borderSide: const BorderSide(color: AppColors.border), 31 | borderRadius: BorderRadius.circular(8), 32 | ), 33 | focusedBorder: const OutlineInputBorder( 34 | borderSide: BorderSide(color: AppColors.primary), 35 | ), 36 | contentPadding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 20.0), 37 | ), 38 | appBarTheme: const AppBarTheme( 39 | elevation: 1, 40 | titleTextStyle: TextStyle(fontSize: 20.0, fontWeight: FontWeight.bold), 41 | ), 42 | textButtonTheme: TextButtonThemeData( 43 | style: TextButton.styleFrom( 44 | foregroundColor: colorScheme.primary, 45 | textStyle: const TextStyle(fontSize: 16.0, fontWeight: FontWeight.bold), 46 | ), 47 | ), 48 | elevatedButtonTheme: ElevatedButtonThemeData( 49 | style: ElevatedButton.styleFrom( 50 | shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), 51 | padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 12.0), 52 | minimumSize: const Size(double.infinity, 48.0), 53 | textStyle: const TextStyle(fontSize: 16.0, fontWeight: FontWeight.bold), 54 | disabledBackgroundColor: AppColors.disabled, 55 | ), 56 | ), 57 | floatingActionButtonTheme: const FloatingActionButtonThemeData( 58 | extendedTextStyle: TextStyle(fontSize: 20.0, fontWeight: FontWeight.bold), 59 | iconSize: 32.0, 60 | ), 61 | outlinedButtonTheme: OutlinedButtonThemeData( 62 | style: OutlinedButton.styleFrom( 63 | shape: RoundedRectangleBorder( 64 | borderRadius: BorderRadius.circular(10), 65 | ), 66 | side: const BorderSide(), 67 | ), 68 | ), 69 | useMaterial3: false, 70 | ), 71 | home: const RootPage(), 72 | ); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /lib/components/alert/discard.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:todomaker/style/color.dart'; 3 | 4 | class DiscardDialog extends StatelessWidget { 5 | final String title; 6 | final Widget message; 7 | final List actions; 8 | 9 | const DiscardDialog({ 10 | super.key, 11 | required this.title, 12 | required this.message, 13 | required this.actions, 14 | }); 15 | @override 16 | Widget build(BuildContext context) { 17 | return AlertDialog( 18 | title: const Icon(Icons.warning, color: TextColor.danger), 19 | content: Column( 20 | mainAxisAlignment: MainAxisAlignment.spaceBetween, 21 | mainAxisSize: MainAxisSize.min, 22 | children: [ 23 | if (title.isNotEmpty) ...[ 24 | Text( 25 | title, 26 | style: const TextStyle( 27 | fontWeight: FontWeight.w600, 28 | fontSize: 16, 29 | ), 30 | textAlign: TextAlign.center, 31 | ), 32 | const SizedBox( 33 | height: 15, 34 | ), 35 | ], 36 | message, 37 | ], 38 | ), 39 | actions: actions, 40 | ); 41 | } 42 | } 43 | 44 | void showDiscardDialog( 45 | BuildContext context, { 46 | required String title, 47 | required String message, 48 | required List actions, 49 | }) { 50 | showDialog( 51 | context: context, 52 | builder: (context) => DiscardDialog( 53 | title: title, 54 | message: Text( 55 | message, 56 | style: const TextStyle( 57 | fontWeight: FontWeight.w300, 58 | fontSize: 14, 59 | ), 60 | ), 61 | actions: actions, 62 | ), 63 | ); 64 | } 65 | -------------------------------------------------------------------------------- /lib/components/alert/help.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_markdown/flutter_markdown.dart'; 3 | import 'package:todomaker/components/grounding_data/list.dart'; 4 | import 'package:todomaker/entity/grounding_data.dart'; 5 | import 'package:todomaker/style/color.dart'; 6 | 7 | // NOTE: 困ったら Widget を渡すようにするか、リソースごとの個別コンポーネントにする 8 | class HelpAlertLayout extends StatelessWidget { 9 | final String title; 10 | final String? subtitle; 11 | final String detailMarkdown; 12 | final List groundings; 13 | 14 | const HelpAlertLayout({ 15 | super.key, 16 | required this.title, 17 | required this.subtitle, 18 | required this.detailMarkdown, 19 | required this.groundings, 20 | }); 21 | 22 | @override 23 | Widget build(BuildContext context) { 24 | final subtitle = this.subtitle; 25 | 26 | final primaryColor = Theme.of(context).colorScheme.primary; 27 | 28 | return AlertDialog( 29 | title: Column( 30 | children: [ 31 | Text(title, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), 32 | const SizedBox(height: 10), 33 | if (subtitle != null && subtitle.isNotEmpty) ...[ 34 | Text(subtitle, style: const TextStyle(fontSize: 14, color: TextColor.darkGray)), 35 | const SizedBox(height: 10), 36 | ], 37 | ], 38 | ), 39 | content: Column( 40 | crossAxisAlignment: CrossAxisAlignment.start, 41 | children: [ 42 | Expanded( 43 | child: SingleChildScrollView( 44 | padding: const EdgeInsets.only(bottom: 20), 45 | child: Column( 46 | crossAxisAlignment: CrossAxisAlignment.start, 47 | children: [ 48 | Text('詳細', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: primaryColor)), 49 | const SizedBox(height: 10), 50 | MarkdownBody(data: detailMarkdown), 51 | ], 52 | ), 53 | ), 54 | ), 55 | const Divider( 56 | height: 1, 57 | color: Colors.black, 58 | ), 59 | const SizedBox(height: 10), 60 | for (final grounding in groundings) GroundingDataList(groundings: [grounding]), 61 | ], 62 | ), 63 | // Default padding is 20; 64 | contentPadding: const EdgeInsets.only(top: 20, left: 20, right: 20, bottom: 0), 65 | actionsPadding: const EdgeInsets.only(bottom: 16, left: 16, right: 16), 66 | actions: [ 67 | TextButton( 68 | onPressed: () { 69 | Navigator.of(context).pop(); 70 | }, 71 | child: const Text('閉じる')), 72 | ], 73 | ); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /lib/components/alert/image_picker.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_hooks/flutter_hooks.dart'; 3 | import 'package:hooks_riverpod/hooks_riverpod.dart'; 4 | import 'package:image_picker/image_picker.dart'; 5 | import 'package:todomaker/components/loading/loading.dart'; 6 | 7 | class ImagePickerDialog extends HookConsumerWidget { 8 | const ImagePickerDialog({super.key}); 9 | 10 | @override 11 | Widget build(BuildContext context, WidgetRef ref) { 12 | final isLoading = useState(false); 13 | final imagePicker = ImagePicker(); 14 | 15 | return AlertDialog( 16 | content: Loading( 17 | isLoading: isLoading.value, 18 | child: Column( 19 | mainAxisSize: MainAxisSize.min, 20 | children: [ 21 | ListTile( 22 | title: const Text('カメラ'), 23 | leading: const Icon(Icons.photo_camera), 24 | onTap: () async { 25 | final XFile? photo = await imagePicker.pickImage(source: ImageSource.camera); 26 | 27 | if (photo != null) { 28 | if (context.mounted) { 29 | Navigator.of(context).pop(photo); 30 | } 31 | } 32 | }, 33 | ), 34 | ListTile( 35 | title: const Text('フォトライブラリ'), 36 | leading: const Icon(Icons.photo_album), 37 | onTap: () async { 38 | final XFile? photo = await imagePicker.pickImage(source: ImageSource.gallery); 39 | if (photo != null) { 40 | if (context.mounted) { 41 | Navigator.of(context).pop(photo); 42 | } 43 | } 44 | }, 45 | ), 46 | ], 47 | ), 48 | ), 49 | actions: [ 50 | TextButton( 51 | onPressed: () { 52 | Navigator.pop(context, null); 53 | }, 54 | child: const Text('閉じる'), 55 | ), 56 | ], 57 | ); 58 | } 59 | } 60 | 61 | Future showImagePickerDialog(BuildContext context) async { 62 | return await showDialog( 63 | context: context, 64 | builder: (context) => const ImagePickerDialog(), 65 | ); 66 | } 67 | -------------------------------------------------------------------------------- /lib/components/alert/ok.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:todomaker/components/loading/loading.dart'; 3 | 4 | class OKDialog extends StatelessWidget { 5 | final IconData icon; 6 | final String title; 7 | final String message; 8 | final Future Function()? ok; 9 | 10 | const OKDialog({ 11 | super.key, 12 | required this.icon, 13 | required this.title, 14 | required this.message, 15 | required this.ok, 16 | }); 17 | 18 | @override 19 | Widget build(BuildContext context) { 20 | return AlertDialog( 21 | title: Icon( 22 | icon, 23 | color: Theme.of(context).primaryColor, 24 | ), 25 | content: Column( 26 | mainAxisAlignment: MainAxisAlignment.spaceBetween, 27 | mainAxisSize: MainAxisSize.min, 28 | children: [ 29 | if (title.isNotEmpty) ...[ 30 | Text(title, 31 | style: const TextStyle( 32 | fontWeight: FontWeight.w600, 33 | fontSize: 16, 34 | )), 35 | const SizedBox( 36 | height: 15, 37 | ), 38 | ], 39 | Text(message, 40 | style: const TextStyle( 41 | fontWeight: FontWeight.w300, 42 | fontSize: 14, 43 | )), 44 | ], 45 | ), 46 | actions: [ 47 | LoadingAction( 48 | action: () async { 49 | final ok = this.ok; 50 | if (ok != null) { 51 | ok(); 52 | } else { 53 | Navigator.of(context).pop(); 54 | } 55 | }, 56 | builder: (action) => TextButton( 57 | onPressed: action, 58 | child: const Text('OK'), 59 | ), 60 | ), 61 | ], 62 | ); 63 | } 64 | } 65 | 66 | Future showOKDialog( 67 | BuildContext context, { 68 | required IconData icon, 69 | required String title, 70 | required String message, 71 | Future Function()? ok, 72 | }) async { 73 | return showDialog( 74 | context: context, 75 | builder: (context) => OKDialog( 76 | icon: icon, 77 | title: title, 78 | message: message, 79 | ok: ok, 80 | ), 81 | ); 82 | } 83 | -------------------------------------------------------------------------------- /lib/components/error/error_alert.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:todomaker/components/loading/loading.dart'; 3 | import 'package:url_launcher/url_launcher.dart'; 4 | 5 | void showErrorAlert(BuildContext? context, Object error) { 6 | if (context == null) { 7 | return; 8 | } 9 | final String title; 10 | final String message; 11 | final String? faqLinkURL; 12 | if (error is FormatException) { 13 | title = '不明なエラーです'; 14 | message = error.message; 15 | faqLinkURL = null; 16 | } else if (error is String) { 17 | title = 'エラーが発生しました'; 18 | message = error; 19 | faqLinkURL = null; 20 | } else { 21 | title = '予期せぬエラーが発生しました'; 22 | message = error.toString(); 23 | faqLinkURL = null; 24 | } 25 | showDialog( 26 | context: context, 27 | builder: (_) { 28 | return ErrorAlert( 29 | title: title, 30 | errorMessage: message, 31 | faqLinkURL: faqLinkURL, 32 | ); 33 | }, 34 | ); 35 | } 36 | 37 | class ErrorAlert extends StatelessWidget { 38 | final String? title; 39 | final String errorMessage; 40 | final String? faqLinkURL; 41 | 42 | const ErrorAlert({super.key, this.title, this.faqLinkURL, required this.errorMessage}); 43 | 44 | @override 45 | Widget build(BuildContext context) { 46 | final faq = faqLinkURL; 47 | return AlertDialog( 48 | title: Text( 49 | title ?? 'エラーが発生しました', 50 | style: const TextStyle( 51 | fontWeight: FontWeight.w600, 52 | fontSize: 16, 53 | color: Colors.black, 54 | ), 55 | ), 56 | content: Text(errorMessage, 57 | style: const TextStyle( 58 | fontWeight: FontWeight.w300, 59 | fontSize: 14, 60 | color: Colors.black, 61 | )), 62 | actions: [ 63 | if (faq != null) 64 | LoadingAction( 65 | action: () async { 66 | launchUrl(Uri.parse(faq)); 67 | }, 68 | builder: (action) => TextButton( 69 | onPressed: action, 70 | child: const Text('FAQを見る'), 71 | ), 72 | ), 73 | LoadingAction( 74 | action: () async { 75 | Navigator.of(context).pop(); 76 | }, 77 | builder: (action) => TextButton( 78 | onPressed: action, 79 | child: const Text('閉じる'), 80 | ), 81 | ), 82 | ], 83 | ); 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /lib/components/form/question_form.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_hooks/flutter_hooks.dart'; 3 | 4 | class QuestionFormSheet extends HookWidget { 5 | const QuestionFormSheet({super.key}); 6 | 7 | @override 8 | Widget build(BuildContext context) { 9 | final question = useState(''); 10 | final focusNode = useFocusNode(); 11 | 12 | WidgetsBinding.instance.addPostFrameCallback((timeStamp) { 13 | focusNode.requestFocus(); 14 | }); 15 | 16 | return DraggableScrollableSheet( 17 | maxChildSize: 1, 18 | initialChildSize: 0.7, 19 | builder: (context, scrollController) { 20 | return Container( 21 | decoration: const BoxDecoration( 22 | color: Colors.white, 23 | borderRadius: BorderRadius.only(topLeft: Radius.circular(16), topRight: Radius.circular(16)), 24 | ), 25 | child: Stack( 26 | children: [ 27 | SingleChildScrollView( 28 | controller: scrollController, 29 | child: Padding( 30 | padding: const EdgeInsets.only(bottom: 20, top: 40, left: 16, right: 16), 31 | child: TextFormField( 32 | focusNode: focusNode, 33 | initialValue: question.value, 34 | minLines: 1, 35 | maxLines: 10, 36 | decoration: const InputDecoration( 37 | border: UnderlineInputBorder(), 38 | enabledBorder: UnderlineInputBorder(), 39 | focusedBorder: UnderlineInputBorder(), 40 | hintText: '例)確定申告の方法,結婚の手続き', 41 | labelText: '手順がわからないものをご記入ください', 42 | ), 43 | onChanged: (value) { 44 | question.value = value; 45 | }, 46 | ), 47 | ), 48 | ), 49 | Positioned( 50 | top: 10, 51 | right: 10, 52 | child: TextButton( 53 | onPressed: () { 54 | Navigator.of(context).pop(question.value); 55 | }, 56 | child: const Text('🤖に聞く'), 57 | ), 58 | ), 59 | ], 60 | ), 61 | ); 62 | }, 63 | ); 64 | } 65 | } 66 | 67 | Future showQuestionFormSheet(BuildContext context) async { 68 | return await showModalBottomSheet( 69 | useSafeArea: true, 70 | backgroundColor: Colors.transparent, 71 | isScrollControlled: true, 72 | context: context, 73 | builder: (context) => const QuestionFormSheet(), 74 | ); 75 | } 76 | -------------------------------------------------------------------------------- /lib/components/grounding_data/list.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:todomaker/entity/grounding_data.dart'; 3 | 4 | import 'package:url_launcher/link.dart'; 5 | 6 | class GroundingDataList extends StatelessWidget { 7 | final List groundings; 8 | const GroundingDataList({super.key, required this.groundings}); 9 | 10 | @override 11 | Widget build(BuildContext context) { 12 | return Column( 13 | crossAxisAlignment: CrossAxisAlignment.start, 14 | children: [ 15 | const Text('情報元', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), 16 | for (final grounding in groundings) ...[ 17 | GroundingDataRow(grounding: grounding), 18 | ], 19 | ], 20 | ); 21 | } 22 | } 23 | 24 | class GroundingDataRow extends StatelessWidget { 25 | final GroundingData grounding; 26 | const GroundingDataRow({super.key, required this.grounding}); 27 | 28 | @override 29 | Widget build(BuildContext context) { 30 | final title = grounding.title; 31 | final url = grounding.url; 32 | if (title == null || url == null) { 33 | return const SizedBox.shrink(); 34 | } 35 | return Link( 36 | uri: Uri.parse(url), 37 | builder: (BuildContext ctx, FollowLink? openLink) { 38 | return TextButton( 39 | onPressed: openLink, 40 | style: ButtonStyle( 41 | tapTargetSize: MaterialTapTargetSize.shrinkWrap, 42 | padding: WidgetStateProperty.all(const EdgeInsets.symmetric(horizontal: 2, vertical: 0)), 43 | ), 44 | child: Text( 45 | title, 46 | style: const TextStyle(fontSize: 12), 47 | ), 48 | ); 49 | }, 50 | ); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /lib/components/loading/bot.dart: -------------------------------------------------------------------------------- 1 | import 'package:animated_text_kit/animated_text_kit.dart'; 2 | import 'package:flutter/material.dart'; 3 | import 'package:flutter_hooks/flutter_hooks.dart'; 4 | import 'package:url_launcher/url_launcher.dart'; 5 | 6 | class BotLoading extends StatelessWidget { 7 | final List messages; 8 | final VoidCallback onStop; 9 | 10 | const BotLoading({super.key, required this.messages, required this.onStop}); 11 | 12 | @override 13 | Widget build(BuildContext context) { 14 | return Positioned.fill( 15 | child: Container( 16 | padding: const EdgeInsets.symmetric(vertical: 12), 17 | decoration: BoxDecoration( 18 | color: Colors.white.withOpacity(0.78), 19 | ), 20 | child: Center( 21 | child: Column( 22 | children: [ 23 | BotChat(messages: messages), 24 | const SizedBox(height: 12), 25 | const Padding( 26 | padding: EdgeInsets.symmetric(horizontal: 20.0), 27 | child: Text('※ 時間がかかり過ぎているようであれば、\n一度停止して再度実行してください。', style: TextStyle(fontSize: 12)), 28 | ), 29 | Padding( 30 | padding: const EdgeInsets.symmetric(horizontal: 12.0), 31 | child: TextButton.icon( 32 | onPressed: () { 33 | onStop(); 34 | }, 35 | icon: const Icon(Icons.stop), 36 | label: const Text('停止'), 37 | ), 38 | ), 39 | ], 40 | ), 41 | ), 42 | ), 43 | ); 44 | } 45 | } 46 | 47 | class BotChat extends HookWidget { 48 | final List messages; 49 | 50 | const BotChat({super.key, required this.messages}); 51 | 52 | @override 53 | Widget build(BuildContext context) { 54 | final tapCount = useState(0); 55 | final showEasterEgg = useState(false); 56 | tapCount.addListener(() { 57 | const threshold = 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1; 58 | if (tapCount.value >= threshold) { 59 | showEasterEgg.value = true; 60 | } 61 | }); 62 | 63 | return GestureDetector( 64 | onTap: () => tapCount.value++, 65 | child: SizedBox( 66 | width: double.infinity, 67 | child: DefaultTextStyle( 68 | style: const TextStyle( 69 | fontSize: 22, 70 | fontWeight: FontWeight.w500, 71 | color: Colors.black, 72 | shadows: [ 73 | Shadow(color: Colors.blueGrey, blurRadius: 2), 74 | ], 75 | ), 76 | child: Row( 77 | children: [ 78 | if (!showEasterEgg.value) ...[ 79 | const Spacer(), 80 | const Text('🤖'), 81 | const SizedBox(width: 2), 82 | Container( 83 | constraints: const BoxConstraints(maxWidth: 310), 84 | child: AnimatedTextKit( 85 | repeatForever: true, 86 | animatedTexts: [ 87 | for (var message in messages) TyperAnimatedText(message, speed: const Duration(milliseconds: 60)), 88 | ], 89 | ), 90 | ), 91 | const Spacer(), 92 | ] else ...[ 93 | const Spacer(), 94 | GestureDetector( 95 | onTap: () async { 96 | final uri = Uri(scheme: 'https', host: 'github.com', path: 'bannzai/zenn_ai_hackathon'); 97 | await launchUrl(uri); 98 | }, 99 | child: Row(children: [ 100 | Image.asset('assets/bannzai.programmer.png', width: 40, height: 40), 101 | const SizedBox(width: 2), 102 | const Text('このリポジトリにスターください⭐️'), 103 | ]), 104 | ), 105 | const Spacer(), 106 | ], 107 | ], 108 | ), 109 | ), 110 | ), 111 | ); 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /lib/components/loading/indicator.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | class Indicator extends StatelessWidget { 4 | const Indicator({ 5 | super.key, 6 | }); 7 | 8 | @override 9 | Widget build(BuildContext context) { 10 | return Center( 11 | child: CircularProgressIndicator( 12 | valueColor: AlwaysStoppedAnimation( 13 | Theme.of(context).primaryColor, 14 | ), 15 | ), 16 | ); 17 | } 18 | } 19 | 20 | class IndicatorPage extends StatelessWidget { 21 | const IndicatorPage({super.key}); 22 | 23 | @override 24 | Widget build(BuildContext context) { 25 | return const Scaffold( 26 | backgroundColor: Colors.white, 27 | body: Indicator(), 28 | ); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /lib/components/loading/loading.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_hooks/flutter_hooks.dart'; 3 | import 'package:todomaker/components/loading/indicator.dart'; 4 | 5 | class Loading extends StatelessWidget { 6 | final bool isLoading; 7 | final Widget child; 8 | 9 | const Loading({super.key, required this.isLoading, required this.child}); 10 | 11 | @override 12 | Widget build(BuildContext context) { 13 | return Stack( 14 | alignment: Alignment.center, 15 | children: [ 16 | child, 17 | if (isLoading) ...[ 18 | const SizedBox( 19 | width: 20, 20 | height: 20, 21 | child: Indicator(), 22 | ), 23 | ], 24 | ], 25 | ); 26 | } 27 | } 28 | 29 | class LoadingAction extends HookWidget { 30 | final Future Function() action; 31 | final Widget Function(VoidCallback?) builder; 32 | 33 | const LoadingAction({ 34 | super.key, 35 | required this.action, 36 | required this.builder, 37 | }); 38 | 39 | @override 40 | Widget build(BuildContext context) { 41 | final isLoading = useState(false); 42 | final future = useMemoized(() => action(), []); 43 | final asyncSnapshot = useFuture(future); 44 | 45 | useEffect(() { 46 | isLoading.value = asyncSnapshot.connectionState == ConnectionState.waiting; 47 | return null; 48 | }, [asyncSnapshot]); 49 | 50 | return Loading( 51 | isLoading: isLoading.value, 52 | child: builder(isLoading.value 53 | ? null 54 | : () async { 55 | await future; 56 | }), 57 | ); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /lib/components/retry/button.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:hooks_riverpod/hooks_riverpod.dart'; 3 | import 'package:todomaker/components/retry/page.dart'; 4 | 5 | class RetryButton extends HookConsumerWidget { 6 | final Object exception; 7 | final StackTrace? stackTrace; 8 | 9 | const RetryButton({ 10 | super.key, 11 | required this.exception, 12 | required this.stackTrace, 13 | }); 14 | 15 | @override 16 | Widget build(BuildContext context, WidgetRef ref) { 17 | debugPrint('Retry: $exception'); 18 | debugPrint('StackTrace: $stackTrace'); 19 | return Padding( 20 | padding: const EdgeInsets.symmetric(horizontal: 12.0), 21 | child: ElevatedButton( 22 | onPressed: () { 23 | Retry.of(context).retry(); 24 | }, 25 | child: const Text('再試行'), 26 | ), 27 | ); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /lib/components/retry/page.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:hooks_riverpod/hooks_riverpod.dart'; 3 | 4 | class Retry extends InheritedWidget { 5 | final VoidCallback retry; 6 | 7 | const Retry({ 8 | super.key, 9 | required this.retry, 10 | required super.child, 11 | }); 12 | 13 | static Retry of(BuildContext context) { 14 | final result = context.dependOnInheritedWidgetOfExactType(); 15 | assert(result != null, 'No Retry found in context'); 16 | return result!; 17 | } 18 | 19 | @override 20 | bool updateShouldNotify(Retry oldWidget) { 21 | return retry != oldWidget.retry; 22 | } 23 | } 24 | 25 | class RetryPage extends HookConsumerWidget { 26 | final Object exception; 27 | final StackTrace? stackTrace; 28 | 29 | const RetryPage({ 30 | super.key, 31 | required this.exception, 32 | required this.stackTrace, 33 | }); 34 | 35 | @override 36 | Widget build(BuildContext context, WidgetRef ref) { 37 | debugPrint('Retry: $exception'); 38 | debugPrint('StackTrace: $stackTrace'); 39 | return Scaffold( 40 | body: Center( 41 | child: Column( 42 | mainAxisAlignment: MainAxisAlignment.center, 43 | children: [ 44 | Text( 45 | exception.toString(), 46 | style: Theme.of(context).textTheme.bodyMedium, 47 | ), 48 | const SizedBox(height: 16), 49 | Padding( 50 | padding: const EdgeInsets.symmetric(horizontal: 12.0), 51 | child: ElevatedButton( 52 | onPressed: () { 53 | Retry.of(context).retry(); 54 | }, 55 | child: const Text('再試行'), 56 | ), 57 | ), 58 | ], 59 | ), 60 | ), 61 | ); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /lib/components/todo/help.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:todomaker/components/alert/help.dart'; 3 | import 'package:todomaker/entity/todo.dart'; 4 | 5 | class TodoHelpButton extends StatelessWidget { 6 | final Todo todo; 7 | const TodoHelpButton({super.key, required this.todo}); 8 | 9 | @override 10 | Widget build(BuildContext context) { 11 | final aiTextResponseMarkdown = todo.aiTextResponseMarkdown; 12 | final groundings = todo.groundings; 13 | if (aiTextResponseMarkdown == null || groundings == null) { 14 | return const SizedBox.shrink(); 15 | } 16 | return IconButton( 17 | onPressed: () { 18 | showDialog( 19 | context: context, 20 | builder: (context) => HelpAlertLayout( 21 | title: todo.content, 22 | subtitle: todo.supplement, 23 | detailMarkdown: aiTextResponseMarkdown, 24 | groundings: groundings, 25 | ), 26 | ); 27 | }, 28 | icon: const Icon(Icons.help_outline, size: 20), 29 | // IconButtonは内部でBoxConstraints(minWidth: 48, minHeight: 48)が設定されているので、これをオーバーライドする 30 | constraints: const BoxConstraints(minWidth: 20, minHeight: 20), 31 | padding: EdgeInsets.zero, 32 | style: ButtonStyle( 33 | padding: WidgetStateProperty.all(EdgeInsets.zero), 34 | ), 35 | ); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /lib/components/todo_locations/ask.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:hooks_riverpod/hooks_riverpod.dart'; 3 | import 'package:todomaker/components/error/error_alert.dart'; 4 | import 'package:todomaker/components/location/form.dart'; 5 | import 'package:todomaker/entity/todo.dart'; 6 | import 'package:todomaker/provider/todo.dart'; 7 | import 'package:todomaker/utils/functions/firebase_functions.dart'; 8 | 9 | class TodoLocationAskAI extends HookConsumerWidget { 10 | final Todo todo; 11 | const TodoLocationAskAI({super.key, required this.todo}); 12 | 13 | @override 14 | Widget build(BuildContext context, WidgetRef ref) { 15 | final todoFillLocation = ref.watch(todoFillLocationProvider); 16 | final locations = todo.locations; 17 | 18 | return TextButton( 19 | style: TextButton.styleFrom( 20 | padding: const EdgeInsets.symmetric(vertical: 0), 21 | tapTargetSize: MaterialTapTargetSize.shrinkWrap, 22 | minimumSize: Size.zero, 23 | ), 24 | onPressed: () async { 25 | showDialog( 26 | context: context, 27 | builder: (context) => LocationForm( 28 | onSubmit: (userLocation) async { 29 | try { 30 | await todoFillLocation( 31 | taskID: todo.taskID, 32 | todoID: todo.id, 33 | userLocation: userLocation, 34 | ); 35 | await functions.fillTodoLocation( 36 | taskID: todo.taskID, 37 | todoID: todo.id, 38 | userLocation: userLocation, 39 | ); 40 | } catch (e) { 41 | if (context.mounted) { 42 | showErrorAlert(context, e.toString()); 43 | } 44 | } 45 | }, 46 | ), 47 | ); 48 | }, 49 | child: Row( 50 | crossAxisAlignment: CrossAxisAlignment.baseline, 51 | textBaseline: TextBaseline.ideographic, 52 | children: [ 53 | const Text('🤖'), 54 | const SizedBox(width: 2), 55 | if (locations != null && locations.isNotEmpty) ...[ 56 | const Expanded(child: Text('関連する位置情報・会場・場所をAIに聞き直す')), 57 | ] else ...[ 58 | const Expanded(child: Text('関連する位置情報・会場・場所をAIに聞く')), 59 | ], 60 | ], 61 | ), 62 | ); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /lib/components/todo_locations/list.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:todomaker/components/todo_locations/row.dart'; 3 | import 'package:todomaker/entity/todo.dart'; 4 | import 'package:todomaker/style/color.dart'; 5 | 6 | class TodoLocationList extends StatelessWidget { 7 | final List todos; 8 | const TodoLocationList({super.key, required this.todos}); 9 | 10 | @override 11 | Widget build(BuildContext context) { 12 | return Padding( 13 | padding: const EdgeInsets.symmetric(horizontal: 20), 14 | child: Column( 15 | crossAxisAlignment: CrossAxisAlignment.start, 16 | children: [ 17 | for (final todo in todos) ...[ 18 | TodoLocationsSection(todo: todo), 19 | const Divider(), 20 | ], 21 | ], 22 | ), 23 | ); 24 | } 25 | } 26 | 27 | class TodoLocationsSection extends StatelessWidget { 28 | final Todo todo; 29 | const TodoLocationsSection({super.key, required this.todo}); 30 | 31 | @override 32 | Widget build(BuildContext context) { 33 | final supplement = todo.supplement; 34 | 35 | return Padding( 36 | padding: const EdgeInsets.symmetric(vertical: 10), 37 | child: Column( 38 | crossAxisAlignment: CrossAxisAlignment.start, 39 | children: [ 40 | Text(todo.content, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), 41 | if (supplement != null && supplement.isNotEmpty) ...[ 42 | Text( 43 | supplement, 44 | style: const TextStyle(fontSize: 14, color: TextColor.darkGray), 45 | maxLines: 2, 46 | overflow: TextOverflow.ellipsis, 47 | ), 48 | ], 49 | TodoLocationsRow(todo: todo), 50 | ], 51 | ), 52 | ); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /lib/components/todo_time_required/functions.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:todomaker/entity/todo.dart'; 4 | import 'package:device_calendar/device_calendar.dart'; 5 | 6 | Future grandPermission({required DeviceCalendarPlugin deviceCalendarPlugin}) async { 7 | var permissionsGranted = await deviceCalendarPlugin.requestPermissions(); 8 | return permissionsGranted.isSuccess && permissionsGranted.data == true; 9 | } 10 | 11 | Future> retrieveCalendars({ 12 | required DeviceCalendarPlugin deviceCalendarPlugin, 13 | }) async { 14 | var calendarsResult = await deviceCalendarPlugin.retrieveCalendars(); 15 | if (calendarsResult.isSuccess && calendarsResult.data != null) { 16 | return calendarsResult.data!; 17 | } 18 | return []; 19 | } 20 | 21 | /// 予定の読み出し(例:過去30日~未来30日) 22 | Future> calendarEvents({ 23 | required Todo todo, 24 | required DeviceCalendarPlugin deviceCalendarPlugin, 25 | required Calendar calendar, 26 | }) async { 27 | final eventIDs = todo.calendarSchedules.map((e) => e.calendarEventID).toList(); 28 | var eventsResult = await deviceCalendarPlugin.retrieveEvents( 29 | calendar.id, 30 | RetrieveEventsParams(eventIds: eventIDs), 31 | ); 32 | final eventResultData = eventsResult.data; 33 | if (eventsResult.isSuccess && eventResultData != null) { 34 | return eventResultData; 35 | } 36 | return []; 37 | } 38 | -------------------------------------------------------------------------------- /lib/entity/app_user.dart: -------------------------------------------------------------------------------- 1 | import 'package:cloud_firestore/cloud_firestore.dart'; 2 | import 'package:freezed_annotation/freezed_annotation.dart'; 3 | import 'package:todomaker/entity/timestamp.dart'; 4 | 5 | part 'app_user.g.dart'; 6 | part 'app_user.freezed.dart'; 7 | 8 | @freezed 9 | class AppUser with _$AppUser { 10 | const AppUser._(); 11 | @JsonSerializable(explicitToJson: true) 12 | const factory AppUser({ 13 | required String id, 14 | @ClientCreatedTimestamp() DateTime? createdDateTime, 15 | @ClientUpdatedTimestamp() DateTime? updatedDateTime, 16 | @ServerCreatedTimestamp() DateTime? serverCreatedDateTime, 17 | @ServerUpdatedTimestamp() DateTime? serverUpdatedDateTime, 18 | }) = _AppUser; 19 | 20 | factory AppUser.fromJson(Map json) => _$AppUserFromJson(json); 21 | } 22 | -------------------------------------------------------------------------------- /lib/entity/app_user.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'app_user.dart'; 4 | 5 | // ************************************************************************** 6 | // JsonSerializableGenerator 7 | // ************************************************************************** 8 | 9 | _$AppUserImpl _$$AppUserImplFromJson(Map json) => _$AppUserImpl( 10 | id: json['id'] as String, 11 | createdDateTime: const ClientCreatedTimestamp().fromJson(json['createdDateTime'] as Timestamp?), 12 | updatedDateTime: const ClientUpdatedTimestamp().fromJson(json['updatedDateTime'] as Timestamp?), 13 | serverCreatedDateTime: const ServerCreatedTimestamp().fromJson(json['serverCreatedDateTime']), 14 | serverUpdatedDateTime: const ServerUpdatedTimestamp().fromJson(json['serverUpdatedDateTime']), 15 | ); 16 | 17 | Map _$$AppUserImplToJson(_$AppUserImpl instance) => { 18 | 'id': instance.id, 19 | 'createdDateTime': const ClientCreatedTimestamp().toJson(instance.createdDateTime), 20 | 'updatedDateTime': const ClientUpdatedTimestamp().toJson(instance.updatedDateTime), 21 | 'serverCreatedDateTime': const ServerCreatedTimestamp().toJson(instance.serverCreatedDateTime), 22 | 'serverUpdatedDateTime': const ServerUpdatedTimestamp().toJson(instance.serverUpdatedDateTime), 23 | }; 24 | -------------------------------------------------------------------------------- /lib/entity/grounding_data.dart: -------------------------------------------------------------------------------- 1 | import 'package:cloud_firestore/cloud_firestore.dart'; 2 | import 'package:freezed_annotation/freezed_annotation.dart'; 3 | import 'package:todomaker/entity/timestamp.dart'; 4 | 5 | part 'grounding_data.g.dart'; 6 | part 'grounding_data.freezed.dart'; 7 | 8 | @freezed 9 | class GroundingData with _$GroundingData { 10 | const GroundingData._(); 11 | @JsonSerializable(explicitToJson: true) 12 | const factory GroundingData({ 13 | required int? index, 14 | required String? url, 15 | required String? title, 16 | @ClientCreatedTimestamp() DateTime? createdDateTime, 17 | @ClientUpdatedTimestamp() DateTime? updatedDateTime, 18 | @ServerCreatedTimestamp() DateTime? serverCreatedDateTime, 19 | @ServerUpdatedTimestamp() DateTime? serverUpdatedDateTime, 20 | }) = _GroundingData; 21 | 22 | factory GroundingData.fromJson(Map json) => _$GroundingDataFromJson(json); 23 | } 24 | 25 | /* 26 | import { z } from "zod"; 27 | 28 | export const GroundingDataSchema = z.object({ 29 | url: z.string().optional(), 30 | title: z.string().optional(), 31 | index: z.number().optional(), 32 | }); 33 | 34 | export type GroundingData = z.infer; 35 | */ 36 | -------------------------------------------------------------------------------- /lib/entity/grounding_data.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'grounding_data.dart'; 4 | 5 | // ************************************************************************** 6 | // JsonSerializableGenerator 7 | // ************************************************************************** 8 | 9 | _$GroundingDataImpl _$$GroundingDataImplFromJson(Map json) => _$GroundingDataImpl( 10 | index: (json['index'] as num?)?.toInt(), 11 | url: json['url'] as String?, 12 | title: json['title'] as String?, 13 | createdDateTime: const ClientCreatedTimestamp().fromJson(json['createdDateTime'] as Timestamp?), 14 | updatedDateTime: const ClientUpdatedTimestamp().fromJson(json['updatedDateTime'] as Timestamp?), 15 | serverCreatedDateTime: const ServerCreatedTimestamp().fromJson(json['serverCreatedDateTime']), 16 | serverUpdatedDateTime: const ServerUpdatedTimestamp().fromJson(json['serverUpdatedDateTime']), 17 | ); 18 | 19 | Map _$$GroundingDataImplToJson(_$GroundingDataImpl instance) => { 20 | 'index': instance.index, 21 | 'url': instance.url, 22 | 'title': instance.title, 23 | 'createdDateTime': const ClientCreatedTimestamp().toJson(instance.createdDateTime), 24 | 'updatedDateTime': const ClientUpdatedTimestamp().toJson(instance.updatedDateTime), 25 | 'serverCreatedDateTime': const ServerCreatedTimestamp().toJson(instance.serverCreatedDateTime), 26 | 'serverUpdatedDateTime': const ServerUpdatedTimestamp().toJson(instance.serverUpdatedDateTime), 27 | }; 28 | -------------------------------------------------------------------------------- /lib/entity/location.dart: -------------------------------------------------------------------------------- 1 | import 'package:freezed_annotation/freezed_annotation.dart'; 2 | 3 | part 'location.g.dart'; 4 | part 'location.freezed.dart'; 5 | 6 | @freezed 7 | class AppLocation with _$AppLocation { 8 | const AppLocation._(); 9 | @JsonSerializable(explicitToJson: true) 10 | const factory AppLocation({ 11 | required String? name, 12 | required String? postalCode, 13 | required String? address, 14 | required String? tel, 15 | required String? email, 16 | }) = _AppLocation; 17 | 18 | factory AppLocation.fromJson(Map json) => _$AppLocationFromJson(json); 19 | } 20 | 21 | /* 22 | import { z } from "zod"; 23 | 24 | export const LocationSchema = z.object({ 25 | name: z.string().describe("場所の名称"), 26 | postalCode: z.string().nullable().describe("郵便番号"), 27 | address: z.string().nullable().describe("住所。郵便番号を除く"), 28 | tel: z.string().nullable().describe("電話番号"), 29 | email: z.string().nullable().describe("メールアドレス"), 30 | }); 31 | */ 32 | -------------------------------------------------------------------------------- /lib/entity/location.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'location.dart'; 4 | 5 | // ************************************************************************** 6 | // JsonSerializableGenerator 7 | // ************************************************************************** 8 | 9 | _$AppLocationImpl _$$AppLocationImplFromJson(Map json) => _$AppLocationImpl( 10 | name: json['name'] as String?, 11 | postalCode: json['postalCode'] as String?, 12 | address: json['address'] as String?, 13 | tel: json['tel'] as String?, 14 | email: json['email'] as String?, 15 | ); 16 | 17 | Map _$$AppLocationImplToJson(_$AppLocationImpl instance) => { 18 | 'name': instance.name, 19 | 'postalCode': instance.postalCode, 20 | 'address': instance.address, 21 | 'tel': instance.tel, 22 | 'email': instance.email, 23 | }; 24 | -------------------------------------------------------------------------------- /lib/entity/location_form.dart: -------------------------------------------------------------------------------- 1 | import 'package:freezed_annotation/freezed_annotation.dart'; 2 | 3 | part 'location_form.g.dart'; 4 | part 'location_form.freezed.dart'; 5 | 6 | @freezed 7 | class LocationFormInfo with _$LocationFormInfo { 8 | const factory LocationFormInfo({ 9 | required String name, 10 | required double? latitude, 11 | required double? longitude, 12 | }) = _LocationFormInfo; 13 | 14 | factory LocationFormInfo.fromJson(Map json) => _$LocationFormInfoFromJson(json); 15 | } 16 | -------------------------------------------------------------------------------- /lib/entity/location_form.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'location_form.dart'; 4 | 5 | // ************************************************************************** 6 | // JsonSerializableGenerator 7 | // ************************************************************************** 8 | 9 | _$LocationFormInfoImpl _$$LocationFormInfoImplFromJson(Map json) => _$LocationFormInfoImpl( 10 | name: json['name'] as String, 11 | latitude: (json['latitude'] as num?)?.toDouble(), 12 | longitude: (json['longitude'] as num?)?.toDouble(), 13 | ); 14 | 15 | Map _$$LocationFormInfoImplToJson(_$LocationFormInfoImpl instance) => { 16 | 'name': instance.name, 17 | 'latitude': instance.latitude, 18 | 'longitude': instance.longitude, 19 | }; 20 | -------------------------------------------------------------------------------- /lib/entity/remote_config_parameter.dart: -------------------------------------------------------------------------------- 1 | import 'package:freezed_annotation/freezed_annotation.dart'; 2 | 3 | part 'remote_config_parameter.freezed.dart'; 4 | part 'remote_config_parameter.g.dart'; 5 | 6 | abstract class RemoteConfigKeys { 7 | static const minimumAppVersion = 'minimumAppVersion'; 8 | } 9 | 10 | abstract class RemoteConfigParameterDefaultValues { 11 | static const minimumAppVersion = '1.0.0'; 12 | } 13 | 14 | @freezed 15 | class RemoteConfigParameter with _$RemoteConfigParameter { 16 | factory RemoteConfigParameter({ 17 | @Default(RemoteConfigParameterDefaultValues.minimumAppVersion) String minimumAppVersion, 18 | }) = _RemoteConfigParameter; 19 | 20 | RemoteConfigParameter._(); 21 | 22 | factory RemoteConfigParameter.fromJson(Map json) => _$RemoteConfigParameterFromJson(json); 23 | } 24 | -------------------------------------------------------------------------------- /lib/entity/remote_config_parameter.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'remote_config_parameter.dart'; 4 | 5 | // ************************************************************************** 6 | // JsonSerializableGenerator 7 | // ************************************************************************** 8 | 9 | _$RemoteConfigParameterImpl _$$RemoteConfigParameterImplFromJson(Map json) => _$RemoteConfigParameterImpl( 10 | minimumAppVersion: json['minimumAppVersion'] as String? ?? RemoteConfigParameterDefaultValues.minimumAppVersion, 11 | ); 12 | 13 | Map _$$RemoteConfigParameterImplToJson(_$RemoteConfigParameterImpl instance) => { 14 | 'minimumAppVersion': instance.minimumAppVersion, 15 | }; 16 | -------------------------------------------------------------------------------- /lib/entity/timestamp.dart: -------------------------------------------------------------------------------- 1 | import 'package:cloud_firestore/cloud_firestore.dart'; 2 | import 'package:freezed_annotation/freezed_annotation.dart'; 3 | 4 | class TimestampConverter implements JsonConverter { 5 | const TimestampConverter(); 6 | 7 | @override 8 | DateTime fromJson(Timestamp json) { 9 | return json.toDate(); 10 | } 11 | 12 | @override 13 | Timestamp toJson(DateTime dateTime) { 14 | return Timestamp.fromDate(dateTime); 15 | } 16 | } 17 | 18 | class NullableTimestampConverter implements JsonConverter { 19 | const NullableTimestampConverter(); 20 | 21 | @override 22 | DateTime? fromJson(Timestamp? json) { 23 | return json?.toDate(); 24 | } 25 | 26 | @override 27 | Timestamp? toJson(DateTime? dateTime) { 28 | if (dateTime == null) { 29 | return null; 30 | } 31 | return Timestamp.fromDate(dateTime); 32 | } 33 | } 34 | 35 | class ClientCreatedTimestamp implements JsonConverter { 36 | const ClientCreatedTimestamp(); 37 | 38 | @override 39 | DateTime? fromJson(Timestamp? timestamp) { 40 | return timestamp?.toDate(); 41 | } 42 | 43 | @override 44 | Timestamp toJson(DateTime? dateTime) { 45 | if (dateTime == null) return Timestamp.fromDate(DateTime.now()); 46 | return Timestamp.fromDate(dateTime); 47 | } 48 | } 49 | 50 | class ClientUpdatedTimestamp implements JsonConverter { 51 | const ClientUpdatedTimestamp(); 52 | 53 | @override 54 | DateTime? fromJson(Timestamp? timestamp) { 55 | return timestamp?.toDate(); 56 | } 57 | 58 | @override 59 | Timestamp? toJson(DateTime? date) { 60 | if (date == null) return null; 61 | return Timestamp.fromDate(date); 62 | } 63 | } 64 | 65 | class ServerCreatedTimestamp implements JsonConverter { 66 | const ServerCreatedTimestamp(); 67 | 68 | @override 69 | DateTime? fromJson(dynamic timestamp) { 70 | timestamp as Timestamp?; 71 | return timestamp?.toDate(); 72 | } 73 | 74 | @override 75 | dynamic toJson(DateTime? dateTime) { 76 | if (dateTime == null) return FieldValue.serverTimestamp(); 77 | return dateTime; 78 | } 79 | } 80 | 81 | class ServerUpdatedTimestamp implements JsonConverter { 82 | const ServerUpdatedTimestamp(); 83 | 84 | @override 85 | DateTime? fromJson(dynamic timestamp) { 86 | timestamp as Timestamp?; 87 | return timestamp?.toDate(); 88 | } 89 | 90 | @override 91 | dynamic toJson(DateTime? date) { 92 | return FieldValue.serverTimestamp(); 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /lib/features/home/page.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:hooks_riverpod/hooks_riverpod.dart'; 3 | import 'package:todomaker/features/tasks/page.dart'; 4 | 5 | enum HomePageTabType { tasks, profile } 6 | 7 | class HomePage extends HookConsumerWidget { 8 | const HomePage({super.key}); 9 | 10 | @override 11 | Widget build(BuildContext context, WidgetRef ref) { 12 | return const TasksPage(); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /lib/features/onboarding/components/body_1.dart: -------------------------------------------------------------------------------- 1 | import 'package:animated_text_kit/animated_text_kit.dart'; 2 | import 'package:flutter/material.dart'; 3 | import 'package:flutter_hooks/flutter_hooks.dart'; 4 | import 'package:todomaker/features/onboarding/components/event.dart'; 5 | 6 | class OnboardingBody1 extends HookWidget { 7 | const OnboardingBody1({ 8 | super.key, 9 | required this.index, 10 | }); 11 | 12 | final ValueNotifier index; 13 | 14 | @override 15 | Widget build(BuildContext context) { 16 | final animationController = useAnimationController(duration: const Duration(milliseconds: 400)); 17 | index.addListener(() { 18 | if (index.value == OnboardingEvent.showTapLabel) { 19 | animationController.forward(); 20 | } 21 | }); 22 | return GestureDetector( 23 | onTap: () => index.value = OnboardingEvent.secondPage, 24 | child: Stack( 25 | alignment: Alignment.bottomCenter, 26 | children: [ 27 | Column( 28 | crossAxisAlignment: CrossAxisAlignment.center, 29 | children: [ 30 | const Spacer(), 31 | OnboardingBot1( 32 | messages: const [ 33 | 'こんにちは!', 34 | 'TODOMakerへようこそ', 35 | ], 36 | onFinished: () => index.value = OnboardingEvent.showTapLabel, 37 | ), 38 | const Spacer(), 39 | ], 40 | ), 41 | if (index.value.index <= OnboardingEvent.showTapLabel.index) ...[ 42 | Positioned( 43 | bottom: 20, 44 | child: SizedBox( 45 | height: 100, 46 | child: FadeTransition( 47 | opacity: Tween(begin: 0, end: 1).animate( 48 | CurvedAnimation( 49 | parent: animationController, 50 | curve: Curves.easeInOut, 51 | ), 52 | ), 53 | child: const Text( 54 | 'タップして次へ', 55 | style: TextStyle( 56 | fontSize: 24, 57 | fontWeight: FontWeight.w500, 58 | color: Colors.black, 59 | shadows: [Shadow(color: Colors.blueGrey, blurRadius: 2)], 60 | ), 61 | ), 62 | ), 63 | ), 64 | ), 65 | ], 66 | ], 67 | ), 68 | ); 69 | } 70 | } 71 | 72 | class OnboardingBot1 extends StatelessWidget { 73 | final List messages; 74 | final VoidCallback onFinished; 75 | 76 | const OnboardingBot1({super.key, required this.messages, required this.onFinished}); 77 | 78 | @override 79 | Widget build(BuildContext context) { 80 | return SizedBox( 81 | width: double.infinity, 82 | height: 100, 83 | child: Row( 84 | mainAxisAlignment: MainAxisAlignment.center, 85 | mainAxisSize: MainAxisSize.min, 86 | children: [ 87 | const Text('🤖'), 88 | const SizedBox(width: 2), 89 | Container( 90 | constraints: const BoxConstraints(maxWidth: 300), 91 | child: AnimatedTextKit( 92 | isRepeatingAnimation: false, 93 | animatedTexts: [ 94 | for (var message in messages) TyperAnimatedText(message, speed: const Duration(milliseconds: 90)), 95 | ], 96 | onNext: (index, isLast) { 97 | debugPrint('index: $index, isLast: $isLast'); 98 | if (isLast) { 99 | onFinished(); 100 | } 101 | }), 102 | ), 103 | ], 104 | ), 105 | ); 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /lib/features/onboarding/components/body_2.dart: -------------------------------------------------------------------------------- 1 | import 'package:animated_text_kit/animated_text_kit.dart'; 2 | import 'package:flutter/material.dart'; 3 | import 'package:flutter_hooks/flutter_hooks.dart'; 4 | import 'package:todomaker/features/onboarding/components/event.dart'; 5 | 6 | class OnboardingBody2 extends HookWidget { 7 | const OnboardingBody2({ 8 | super.key, 9 | required this.index, 10 | }); 11 | 12 | final ValueNotifier index; 13 | 14 | @override 15 | Widget build(BuildContext context) { 16 | final animationController = useAnimationController(duration: const Duration(milliseconds: 400)); 17 | index.addListener(() { 18 | if (index.value == OnboardingEvent.showTapLabel) { 19 | animationController.forward(); 20 | } 21 | }); 22 | return GestureDetector( 23 | onTap: () => index.value = OnboardingEvent.secondPage, 24 | child: Stack( 25 | alignment: Alignment.bottomCenter, 26 | children: [ 27 | Column( 28 | crossAxisAlignment: CrossAxisAlignment.center, 29 | children: [ 30 | const Spacer(), 31 | OnboardingBot1( 32 | messages: const [ 33 | 'こんにちは!', 34 | 'TODOMakerへようこそ', 35 | ], 36 | onFinished: () => index.value = OnboardingEvent.showTapLabel, 37 | ), 38 | const Spacer(), 39 | ], 40 | ), 41 | if (index.value.index <= OnboardingEvent.showTapLabel.index) ...[ 42 | Positioned( 43 | bottom: 20, 44 | child: SizedBox( 45 | height: 100, 46 | child: FadeTransition( 47 | opacity: Tween(begin: 0, end: 1).animate( 48 | CurvedAnimation( 49 | parent: animationController, 50 | curve: Curves.easeInOut, 51 | ), 52 | ), 53 | child: const Text( 54 | 'タップして次へ', 55 | style: TextStyle( 56 | fontSize: 24, 57 | fontWeight: FontWeight.w500, 58 | color: Colors.black, 59 | shadows: [Shadow(color: Colors.blueGrey, blurRadius: 2)], 60 | ), 61 | ), 62 | ), 63 | ), 64 | ), 65 | ], 66 | ], 67 | ), 68 | ); 69 | } 70 | } 71 | 72 | class OnboardingBot1 extends StatelessWidget { 73 | final List messages; 74 | final VoidCallback onFinished; 75 | 76 | const OnboardingBot1({super.key, required this.messages, required this.onFinished}); 77 | 78 | @override 79 | Widget build(BuildContext context) { 80 | return SizedBox( 81 | width: double.infinity, 82 | height: 100, 83 | child: Row( 84 | mainAxisAlignment: MainAxisAlignment.center, 85 | mainAxisSize: MainAxisSize.min, 86 | children: [ 87 | const Text('🤖'), 88 | const SizedBox(width: 2), 89 | Container( 90 | constraints: const BoxConstraints(maxWidth: 300), 91 | child: AnimatedTextKit( 92 | isRepeatingAnimation: false, 93 | animatedTexts: [ 94 | for (var message in messages) ...[ 95 | TyperAnimatedText( 96 | message, 97 | speed: const Duration(milliseconds: 90), 98 | ), 99 | ], 100 | ], 101 | onNext: (index, isLast) { 102 | debugPrint('index: $index, isLast: $isLast'); 103 | if (isLast) { 104 | onFinished(); 105 | } 106 | }), 107 | ), 108 | ], 109 | ), 110 | ); 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /lib/features/onboarding/components/event.dart: -------------------------------------------------------------------------------- 1 | enum OnboardingEvent { 2 | initial, 3 | showTapLabel, 4 | secondPage, 5 | } 6 | -------------------------------------------------------------------------------- /lib/features/onboarding/const.dart: -------------------------------------------------------------------------------- 1 | const onboardingResolvedKey = 'onboardingResolvedKey'; 2 | -------------------------------------------------------------------------------- /lib/features/onboarding/page.dart: -------------------------------------------------------------------------------- 1 | import 'package:animated_text_kit/animated_text_kit.dart'; 2 | import 'package:flutter/material.dart'; 3 | import 'package:flutter_hooks/flutter_hooks.dart'; 4 | import 'package:todomaker/features/onboarding/components/body_1.dart'; 5 | import 'package:todomaker/features/onboarding/components/event.dart'; 6 | 7 | class OnboardingPage extends HookWidget { 8 | final ValueNotifier isOnboardingResolved; 9 | const OnboardingPage({super.key, required this.isOnboardingResolved}); 10 | 11 | @override 12 | Widget build(BuildContext context) { 13 | final index = useState(OnboardingEvent.initial); 14 | 15 | return Scaffold( 16 | body: DefaultTextStyle( 17 | style: const TextStyle( 18 | fontSize: 24, 19 | fontWeight: FontWeight.w500, 20 | color: Colors.black, 21 | shadows: [ 22 | Shadow(color: Colors.blueGrey, blurRadius: 2), 23 | ], 24 | ), 25 | child: SafeArea( 26 | child: Stack( 27 | children: [ 28 | if (index.value.index <= OnboardingEvent.showTapLabel.index) ...[ 29 | OnboardingBody1(index: index), 30 | ], 31 | ], 32 | ), 33 | ), 34 | ), 35 | ); 36 | } 37 | } 38 | 39 | class OnboardingBot extends StatelessWidget { 40 | final List messages; 41 | 42 | const OnboardingBot({super.key, required this.messages}); 43 | 44 | @override 45 | Widget build(BuildContext context) { 46 | return SizedBox( 47 | width: double.infinity, 48 | child: Row( 49 | mainAxisSize: MainAxisSize.min, 50 | children: [ 51 | const Text('🤖'), 52 | const SizedBox(width: 2), 53 | Container( 54 | constraints: const BoxConstraints(maxWidth: 300), 55 | child: AnimatedTextKit( 56 | repeatForever: true, 57 | animatedTexts: [ 58 | for (var message in messages) TyperAnimatedText(message, speed: const Duration(milliseconds: 60)), 59 | ], 60 | ), 61 | ), 62 | ], 63 | ), 64 | ); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /lib/features/onboarding/resolver.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_hooks/flutter_hooks.dart'; 3 | import 'package:hooks_riverpod/hooks_riverpod.dart'; 4 | import 'package:todomaker/features/onboarding/const.dart'; 5 | import 'package:todomaker/features/onboarding/page.dart'; 6 | import 'package:todomaker/provider/shared_preferences.dart'; 7 | 8 | class OnboardingResolver extends HookConsumerWidget { 9 | final WidgetBuilder builder; 10 | 11 | const OnboardingResolver({super.key, required this.builder}); 12 | 13 | @override 14 | Widget build(BuildContext context, WidgetRef ref) { 15 | final isOnboardingResolved = ref.watch(sharedPreferencesProvider).getBool(onboardingResolvedKey); 16 | final isOnboardingResolvedState = useState(isOnboardingResolved ?? false); 17 | isOnboardingResolvedState.addListener(() { 18 | if (isOnboardingResolvedState.value) { 19 | return; 20 | } 21 | }); 22 | 23 | if (isOnboardingResolvedState.value) { 24 | return builder(context); 25 | } 26 | 27 | return OnboardingPage(isOnboardingResolved: isOnboardingResolvedState); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /lib/features/root/page.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:hooks_riverpod/hooks_riverpod.dart'; 3 | import 'package:todomaker/features/home/page.dart'; 4 | import 'package:todomaker/features/root/resolver/app_user_create.dart'; 5 | import 'package:todomaker/features/root/resolver/auth.dart'; 6 | import 'package:todomaker/features/root/resolver/database.dart'; 7 | import 'package:todomaker/features/root/resolver/force_update.dart'; 8 | 9 | class RootPage extends HookConsumerWidget { 10 | const RootPage({super.key}); 11 | 12 | @override 13 | Widget build(BuildContext context, WidgetRef ref) { 14 | return MediaQuery( 15 | data: MediaQuery.of(context).copyWith(alwaysUse24HourFormat: true), 16 | child: AuthResolver(builder: (context, user) { 17 | return UserDatabaseResolver(builder: (context) { 18 | return AppUserCreateResolver(builder: (context) { 19 | return ForceUpdateResolver(builder: (context) { 20 | // return OnboardingResolver(builder: (context) { 21 | return const HomePage(); 22 | // }); 23 | }); 24 | }); 25 | }); 26 | }), 27 | ); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /lib/features/root/resolver/app_user_create.dart: -------------------------------------------------------------------------------- 1 | // ignore: implementation_imports 2 | import 'package:cloud_firestore/cloud_firestore.dart'; 3 | import 'package:flutter/material.dart'; 4 | import 'package:flutter_hooks/flutter_hooks.dart'; 5 | import 'package:hooks_riverpod/hooks_riverpod.dart'; 6 | import 'package:todomaker/components/loading/indicator.dart'; 7 | import 'package:todomaker/components/retry/page.dart'; 8 | import 'package:todomaker/entity/app_user.dart'; 9 | import 'package:todomaker/features/root/resolver/database.dart'; 10 | import 'package:riverpod_annotation/riverpod_annotation.dart'; 11 | import 'package:todomaker/provider/app_user.dart'; 12 | 13 | part 'app_user_create.g.dart'; 14 | 15 | class AppUserCreate { 16 | final UserDatabase userDatabase; 17 | 18 | AppUserCreate({required this.userDatabase}); 19 | 20 | Future call() async { 21 | final docRef = userDatabase.userReference(); 22 | final docData = await docRef.get(); 23 | if (docData.exists) { 24 | return; 25 | } 26 | await docRef.set( 27 | AppUser(id: docRef.id), 28 | SetOptions(merge: true), 29 | ); 30 | } 31 | } 32 | 33 | @Riverpod(dependencies: [userDatabase]) 34 | AppUserCreate appUserCreate(AppUserCreateRef ref) { 35 | return AppUserCreate(userDatabase: ref.watch(userDatabaseProvider)); 36 | } 37 | 38 | class AppUserCreateResolver extends HookConsumerWidget { 39 | final Widget Function(BuildContext) builder; 40 | 41 | const AppUserCreateResolver({super.key, required this.builder}); 42 | 43 | @override 44 | Widget build(BuildContext context, WidgetRef ref) { 45 | final appUser = ref.watch(appUserProvider); 46 | final appUserCreate = ref.watch(appUserCreateProvider); 47 | 48 | useEffect(() { 49 | appUserCreate(); 50 | return null; 51 | }, []); 52 | 53 | return Retry( 54 | retry: () => ref.invalidate(appUserProvider), 55 | child: () { 56 | return appUser.when( 57 | data: (appUser) => builder(context), 58 | error: (e, st) => RetryPage( 59 | exception: e, 60 | stackTrace: st, 61 | ), 62 | loading: () => const IndicatorPage(), 63 | ); 64 | }(), 65 | ); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /lib/features/root/resolver/app_user_create.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'app_user_create.dart'; 4 | 5 | // ************************************************************************** 6 | // RiverpodGenerator 7 | // ************************************************************************** 8 | 9 | String _$appUserCreateHash() => r'5347e10fddf40fb2c852b6fe39caccd7d8b96058'; 10 | 11 | /// See also [appUserCreate]. 12 | @ProviderFor(appUserCreate) 13 | final appUserCreateProvider = AutoDisposeProvider.internal( 14 | appUserCreate, 15 | name: r'appUserCreateProvider', 16 | debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') ? null : _$appUserCreateHash, 17 | dependencies: [userDatabaseProvider], 18 | allTransitiveDependencies: {userDatabaseProvider, ...?userDatabaseProvider.allTransitiveDependencies}, 19 | ); 20 | 21 | typedef AppUserCreateRef = AutoDisposeProviderRef; 22 | // ignore_for_file: type=lint 23 | // ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member 24 | -------------------------------------------------------------------------------- /lib/features/root/resolver/auth.dart: -------------------------------------------------------------------------------- 1 | import 'package:firebase_auth/firebase_auth.dart'; 2 | import 'package:flutter/material.dart'; 3 | import 'package:flutter_hooks/flutter_hooks.dart'; 4 | import 'package:hooks_riverpod/hooks_riverpod.dart'; 5 | import 'package:todomaker/components/loading/indicator.dart'; 6 | import 'package:todomaker/components/retry/page.dart'; 7 | import 'package:riverpod_annotation/riverpod_annotation.dart'; 8 | 9 | part 'auth.g.dart'; 10 | 11 | @Riverpod(keepAlive: true, dependencies: []) 12 | Stream firebaseUserChanges(FirebaseUserChangesRef ref) { 13 | return FirebaseAuth.instance.userChanges(); 14 | } 15 | 16 | class AuthResolver extends HookConsumerWidget { 17 | final Widget Function(BuildContext, User) builder; 18 | 19 | const AuthResolver({super.key, required this.builder}); 20 | 21 | @override 22 | Widget build(BuildContext context, WidgetRef ref) { 23 | final firebaseUserChanges = ref.watch(firebaseUserChangesProvider); 24 | 25 | return Retry( 26 | retry: () => ref.invalidate(firebaseUserChangesProvider), 27 | child: () { 28 | return firebaseUserChanges.when( 29 | data: (user) { 30 | debugPrint('user.uid: ${user?.uid}'); 31 | if (user == null) { 32 | return SignInResolver( 33 | builder: (context, user) => builder(context, user), 34 | ); 35 | } else { 36 | return builder(context, user); 37 | } 38 | }, 39 | error: (e, st) => RetryPage(exception: e, stackTrace: st), 40 | loading: () => const IndicatorPage(), 41 | ); 42 | }(), 43 | ); 44 | } 45 | } 46 | 47 | class SignInResolver extends HookConsumerWidget { 48 | final Widget Function(BuildContext, User) builder; 49 | 50 | const SignInResolver({super.key, required this.builder}); 51 | 52 | @override 53 | Widget build(BuildContext context, WidgetRef ref) { 54 | final user = useState(FirebaseAuth.instance.currentUser); 55 | final userValue = user.value; 56 | 57 | useEffect(() { 58 | void f() async { 59 | if (userValue == null) { 60 | await FirebaseAuth.instance.signInAnonymously(); 61 | } 62 | } 63 | 64 | f(); 65 | return null; 66 | }, [user.value]); 67 | 68 | if (userValue == null) { 69 | return const IndicatorPage(); 70 | } else { 71 | return builder(context, userValue); 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /lib/features/root/resolver/auth.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'auth.dart'; 4 | 5 | // ************************************************************************** 6 | // RiverpodGenerator 7 | // ************************************************************************** 8 | 9 | String _$firebaseUserChangesHash() => r'7640fb519be2781b52dfbaa306b26ede6d0fc3cc'; 10 | 11 | /// See also [firebaseUserChanges]. 12 | @ProviderFor(firebaseUserChanges) 13 | final firebaseUserChangesProvider = StreamProvider.internal( 14 | firebaseUserChanges, 15 | name: r'firebaseUserChangesProvider', 16 | debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') ? null : _$firebaseUserChangesHash, 17 | dependencies: const [], 18 | allTransitiveDependencies: const {}, 19 | ); 20 | 21 | typedef FirebaseUserChangesRef = StreamProviderRef; 22 | // ignore_for_file: type=lint 23 | // ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member 24 | -------------------------------------------------------------------------------- /lib/features/root/resolver/database.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'database.dart'; 4 | 5 | // ************************************************************************** 6 | // RiverpodGenerator 7 | // ************************************************************************** 8 | 9 | String _$userDatabaseHash() => r'f303f1ceb234a43ec9b0dcdeace28917c9f85c14'; 10 | 11 | /// See also [userDatabase]. 12 | @ProviderFor(userDatabase) 13 | final userDatabaseProvider = Provider.internal( 14 | userDatabase, 15 | name: r'userDatabaseProvider', 16 | debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') ? null : _$userDatabaseHash, 17 | dependencies: [firebaseUserChangesProvider], 18 | allTransitiveDependencies: {firebaseUserChangesProvider, ...?firebaseUserChangesProvider.allTransitiveDependencies}, 19 | ); 20 | 21 | typedef UserDatabaseRef = ProviderRef; 22 | // ignore_for_file: type=lint 23 | // ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member 24 | -------------------------------------------------------------------------------- /lib/features/root/resolver/force_update.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_hooks/flutter_hooks.dart'; 3 | import 'package:hooks_riverpod/hooks_riverpod.dart'; 4 | import 'package:todomaker/components/alert/ok.dart'; 5 | import 'package:todomaker/components/loading/indicator.dart'; 6 | import 'package:todomaker/provider/force_update.dart'; 7 | import 'package:todomaker/utils/analytics/error.dart'; 8 | import 'package:todomaker/utils/config/platform.dart'; 9 | import 'package:url_launcher/url_launcher.dart'; 10 | 11 | class ForceUpdateResolver extends HookConsumerWidget { 12 | final Widget Function(BuildContext) builder; 13 | 14 | const ForceUpdateResolver({ 15 | super.key, 16 | required this.builder, 17 | }); 18 | 19 | @override 20 | Widget build(BuildContext context, WidgetRef ref) { 21 | final checkForceUpdate = ref.watch(checkForceUpdateProvider); 22 | final shouldForceUpdate = useState(false); 23 | useEffect(() { 24 | void f() async { 25 | try { 26 | // 多少データが変更される可能性があるが、それは許容する。ほとんど問題はないはず 27 | // 起動時間を優先する 28 | if (await checkForceUpdate()) { 29 | shouldForceUpdate.value = true; 30 | } 31 | } catch (e, st) { 32 | errorLogger.recordError(e, st); 33 | } 34 | } 35 | 36 | f(); 37 | return null; 38 | }, [checkForceUpdate]); 39 | 40 | if (shouldForceUpdate.value) { 41 | Future.microtask(() async { 42 | if (context.mounted) { 43 | await showOKDialog(context, 44 | icon: Icons.error, title: 'アプリをアップデートしてください', message: 'お使いのアプリのバージョンのアップデートをお願いしております。$storeNameから最新バージョンにアップデートしてください', ok: () async { 45 | await launchUrl( 46 | Uri.parse(forceUpdateStoreURL), 47 | mode: LaunchMode.externalApplication, 48 | ); 49 | }); 50 | } 51 | }); 52 | return const IndicatorPage(); 53 | } 54 | 55 | return builder(context); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /lib/features/task/components/location/ask.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:hooks_riverpod/hooks_riverpod.dart'; 3 | import 'package:todomaker/components/error/error_alert.dart'; 4 | import 'package:todomaker/components/location/form.dart'; 5 | import 'package:todomaker/entity/task.dart'; 6 | import 'package:todomaker/provider/task.dart'; 7 | import 'package:todomaker/utils/functions/firebase_functions.dart'; 8 | 9 | class TaskLocationAskAI extends HookConsumerWidget { 10 | final TaskPrepared task; 11 | const TaskLocationAskAI({super.key, required this.task}); 12 | 13 | @override 14 | Widget build(BuildContext context, WidgetRef ref) { 15 | final taskFillLocation = ref.watch(taskFillLocationProvider); 16 | final locations = task.locations; 17 | 18 | return TextButton( 19 | style: TextButton.styleFrom( 20 | padding: const EdgeInsets.symmetric(vertical: 0), 21 | tapTargetSize: MaterialTapTargetSize.shrinkWrap, 22 | minimumSize: Size.zero, 23 | ), 24 | onPressed: () async { 25 | showDialog( 26 | context: context, 27 | builder: (context) => LocationForm( 28 | onSubmit: (userLocation) async { 29 | try { 30 | await taskFillLocation(taskID: task.id, userLocation: userLocation); 31 | await functions.fillLocation( 32 | taskID: task.id, 33 | userLocation: userLocation, 34 | ); 35 | } catch (e) { 36 | if (context.mounted) { 37 | showErrorAlert(context, e.toString()); 38 | } 39 | } 40 | }, 41 | ), 42 | ); 43 | }, 44 | child: Row( 45 | crossAxisAlignment: CrossAxisAlignment.baseline, 46 | textBaseline: TextBaseline.ideographic, 47 | children: [ 48 | const Text('🤖'), 49 | const SizedBox(width: 2), 50 | if (locations != null && locations.isNotEmpty) ...[ 51 | const Expanded(child: Text('関連する位置情報・会場・場所をAIに聞き直す')), 52 | ] else ...[ 53 | const Expanded(child: Text('関連する位置情報・会場・場所をAIに聞く')), 54 | ], 55 | ], 56 | ), 57 | ); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /lib/features/todo/components/memo.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:todomaker/entity/todo.dart'; 3 | 4 | class TodoListMemoTile extends StatelessWidget { 5 | final Todo todo; 6 | const TodoListMemoTile({ 7 | super.key, 8 | required this.todo, 9 | }); 10 | 11 | @override 12 | Widget build(BuildContext context) { 13 | return ListTile( 14 | leading: const Icon(Icons.edit), 15 | title: const Text('メモ'), 16 | onTap: () async {}, 17 | ); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /lib/features/todo_locations/page.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:todomaker/components/todo_locations/list.dart'; 3 | import 'package:todomaker/entity/todo.dart'; 4 | 5 | class TodoLocationsPage extends StatelessWidget { 6 | final List todos; 7 | const TodoLocationsPage({super.key, required this.todos}); 8 | 9 | @override 10 | Widget build(BuildContext context) { 11 | return Scaffold( 12 | appBar: AppBar( 13 | title: const Text('関連場所一覧'), 14 | ), 15 | body: SafeArea( 16 | child: SingleChildScrollView( 17 | padding: const EdgeInsets.symmetric(vertical: 20), 18 | child: TodoLocationList(todos: todos), 19 | ), 20 | ), 21 | ); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /lib/main.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:firebase_core/firebase_core.dart'; 4 | import 'package:firebase_crashlytics/firebase_crashlytics.dart'; 5 | import 'package:flutter/foundation.dart'; 6 | import 'package:flutter/material.dart'; 7 | import 'package:geocoding/geocoding.dart'; 8 | import 'package:hooks_riverpod/hooks_riverpod.dart'; 9 | import 'package:shared_preferences/shared_preferences.dart'; 10 | import 'package:todomaker/app.dart'; 11 | import 'package:todomaker/provider/shared_preferences.dart'; 12 | import 'package:todomaker/utils/config/remote_config.dart'; 13 | import 'package:todomaker/utils/log/debug_print.dart'; 14 | 15 | void main() async { 16 | runZonedGuarded(() async { 17 | if (kDebugMode) { 18 | overrideDebugPrint(); 19 | } 20 | WidgetsFlutterBinding.ensureInitialized(); 21 | await Firebase.initializeApp(); 22 | 23 | final (sharedPreferences, _) = await ( 24 | SharedPreferences.getInstance(), 25 | setupRemoteConfig(), 26 | ).wait; 27 | 28 | // MEMO: FirebaseCrashlytics#recordFlutterError called dumpErrorToConsole in function. 29 | FlutterError.onError = FirebaseCrashlytics.instance.recordFlutterError; 30 | 31 | setLocaleIdentifier('ja'); 32 | 33 | runApp( 34 | ProviderScope( 35 | overrides: [ 36 | sharedPreferencesProvider.overrideWith((ref) => sharedPreferences), 37 | ], 38 | child: const App(), 39 | ), 40 | ); 41 | }, (error, stack) => FirebaseCrashlytics.instance.recordError(error, stack)); 42 | } 43 | -------------------------------------------------------------------------------- /lib/provider/app_user.dart: -------------------------------------------------------------------------------- 1 | import 'package:firebase_auth/firebase_auth.dart'; 2 | import 'package:riverpod_annotation/riverpod_annotation.dart'; 3 | import 'package:todomaker/entity/app_user.dart'; 4 | import 'package:todomaker/features/root/resolver/database.dart'; 5 | 6 | part 'app_user.g.dart'; 7 | 8 | @Riverpod(dependencies: [appUser]) 9 | String appUserID(AppUserIDRef ref) { 10 | return ref.watch(appUserProvider).asData?.value.id ?? FirebaseAuth.instance.currentUser!.uid; 11 | } 12 | 13 | @Riverpod(dependencies: [userDatabase]) 14 | Stream appUser(AppUserRef ref) { 15 | final database = ref.watch(userDatabaseProvider); 16 | return database.userReference().snapshots().map((event) => event.data()!); 17 | } 18 | -------------------------------------------------------------------------------- /lib/provider/app_user.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'app_user.dart'; 4 | 5 | // ************************************************************************** 6 | // RiverpodGenerator 7 | // ************************************************************************** 8 | 9 | String _$appUserIDHash() => r'4f06a266d5801bd41331f035ac4916bc2380ea3f'; 10 | 11 | /// See also [appUserID]. 12 | @ProviderFor(appUserID) 13 | final appUserIDProvider = AutoDisposeProvider.internal( 14 | appUserID, 15 | name: r'appUserIDProvider', 16 | debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') ? null : _$appUserIDHash, 17 | dependencies: [appUserProvider], 18 | allTransitiveDependencies: {appUserProvider, ...?appUserProvider.allTransitiveDependencies}, 19 | ); 20 | 21 | typedef AppUserIDRef = AutoDisposeProviderRef; 22 | String _$appUserHash() => r'd4db768e8c250fd8a19d1bb85ead8d5df365a312'; 23 | 24 | /// See also [appUser]. 25 | @ProviderFor(appUser) 26 | final appUserProvider = AutoDisposeStreamProvider.internal( 27 | appUser, 28 | name: r'appUserProvider', 29 | debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') ? null : _$appUserHash, 30 | dependencies: [userDatabaseProvider], 31 | allTransitiveDependencies: {userDatabaseProvider, ...?userDatabaseProvider.allTransitiveDependencies}, 32 | ); 33 | 34 | typedef AppUserRef = AutoDisposeStreamProviderRef; 35 | // ignore_for_file: type=lint 36 | // ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member 37 | -------------------------------------------------------------------------------- /lib/provider/force_update.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:hooks_riverpod/hooks_riverpod.dart'; 4 | import 'package:todomaker/entity/remote_config_parameter.dart'; 5 | import 'package:todomaker/provider/remote_config_parameter.dart'; 6 | import 'package:todomaker/utils/analytics/analytics.dart'; 7 | import 'package:todomaker/utils/config/version.dart'; 8 | 9 | final checkForceUpdateProvider = Provider.autoDispose( 10 | (ref) => CheckForceUpdate( 11 | remoteConfigParameter: ref.watch( 12 | remoteConfigParameterProvider, 13 | ), 14 | ), 15 | ); 16 | 17 | class CheckForceUpdate { 18 | final RemoteConfigParameter remoteConfigParameter; 19 | 20 | CheckForceUpdate({required this.remoteConfigParameter}); 21 | 22 | // Return false: should not force update 23 | // Return true: should force update 24 | Future call() async { 25 | final config = remoteConfigParameter; 26 | final packageVersion = await Version.fromPackage(); 27 | 28 | final forceUpdate = packageVersion.isLessThan(Version.parse(config.minimumAppVersion)); 29 | if (forceUpdate) { 30 | analytics.logEvent( 31 | name: 'screen_type_force_update', 32 | parameters: { 33 | 'package_version': packageVersion.toString(), 34 | 'minimum_app_version': config.minimumAppVersion, 35 | }, 36 | ); 37 | } 38 | return forceUpdate; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /lib/provider/remote_config_parameter.dart: -------------------------------------------------------------------------------- 1 | import 'package:firebase_remote_config/firebase_remote_config.dart'; 2 | import 'package:todomaker/entity/remote_config_parameter.dart'; 3 | import 'package:todomaker/utils/config/remote_config.dart'; 4 | import 'package:riverpod_annotation/riverpod_annotation.dart'; 5 | 6 | part 'remote_config_parameter.g.dart'; 7 | 8 | @Riverpod() 9 | RemoteConfigParameter remoteConfigParameter(RemoteConfigParameterRef ref) { 10 | // fetchAndActiveをentrypointで完了しているので値が取れる想定 11 | return RemoteConfigParameter( 12 | minimumAppVersion: remoteConfig.getStringOrDefault( 13 | RemoteConfigKeys.minimumAppVersion, 14 | RemoteConfigParameterDefaultValues.minimumAppVersion, 15 | ), 16 | ); 17 | } 18 | 19 | extension RemoteConfigExt on FirebaseRemoteConfig { 20 | bool getBoolOrDefault(String key, bool defaultValue) { 21 | try { 22 | return getAll().containsKey(key) ? getBool(key) : defaultValue; 23 | } catch (error) { 24 | return defaultValue; 25 | } 26 | } 27 | 28 | int getIntOrDefault(String key, int defaultValue) { 29 | try { 30 | return getAll().containsKey(key) ? getInt(key) : defaultValue; 31 | } catch (error) { 32 | return defaultValue; 33 | } 34 | } 35 | 36 | String getStringOrDefault(String key, String defaultValue) { 37 | try { 38 | return getAll().containsKey(key) ? getString(key) : defaultValue; 39 | } catch (error) { 40 | return defaultValue; 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /lib/provider/remote_config_parameter.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'remote_config_parameter.dart'; 4 | 5 | // ************************************************************************** 6 | // RiverpodGenerator 7 | // ************************************************************************** 8 | 9 | String _$remoteConfigParameterHash() => r'ad696ca938d52e5a3c3c4b9f3ab9be996f5fcaac'; 10 | 11 | /// See also [remoteConfigParameter]. 12 | @ProviderFor(remoteConfigParameter) 13 | final remoteConfigParameterProvider = AutoDisposeProvider.internal( 14 | remoteConfigParameter, 15 | name: r'remoteConfigParameterProvider', 16 | debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') ? null : _$remoteConfigParameterHash, 17 | dependencies: null, 18 | allTransitiveDependencies: null, 19 | ); 20 | 21 | typedef RemoteConfigParameterRef = AutoDisposeProviderRef; 22 | // ignore_for_file: type=lint 23 | // ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member 24 | -------------------------------------------------------------------------------- /lib/provider/shared_preferences.dart: -------------------------------------------------------------------------------- 1 | import 'package:riverpod_annotation/riverpod_annotation.dart'; 2 | import 'package:shared_preferences/shared_preferences.dart'; 3 | 4 | part 'shared_preferences.g.dart'; 5 | 6 | // overrideする前提なので.autoDisposeはつけない=(keeyAlive: trueにする)。またこれに依存したProviderもkeepAlive: trueにする必要がある 7 | @Riverpod(keepAlive: true, dependencies: []) 8 | SharedPreferences sharedPreferences(SharedPreferencesRef ref) { 9 | throw UnimplementedError('sharedPreferencesProvider is not implemented'); 10 | } 11 | -------------------------------------------------------------------------------- /lib/provider/shared_preferences.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'shared_preferences.dart'; 4 | 5 | // ************************************************************************** 6 | // RiverpodGenerator 7 | // ************************************************************************** 8 | 9 | String _$sharedPreferencesHash() => r'9bef4c6887ebe74597e857ffe72ade05522602aa'; 10 | 11 | /// See also [sharedPreferences]. 12 | @ProviderFor(sharedPreferences) 13 | final sharedPreferencesProvider = Provider.internal( 14 | sharedPreferences, 15 | name: r'sharedPreferencesProvider', 16 | debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') ? null : _$sharedPreferencesHash, 17 | dependencies: const [], 18 | allTransitiveDependencies: const {}, 19 | ); 20 | 21 | typedef SharedPreferencesRef = ProviderRef; 22 | // ignore_for_file: type=lint 23 | // ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member 24 | -------------------------------------------------------------------------------- /lib/provider/task.dart: -------------------------------------------------------------------------------- 1 | import 'package:cloud_firestore/cloud_firestore.dart'; 2 | import 'package:riverpod_annotation/riverpod_annotation.dart'; 3 | import 'package:todomaker/entity/location_form.dart'; 4 | import 'package:todomaker/entity/task.dart'; 5 | import 'package:todomaker/features/root/resolver/database.dart'; 6 | 7 | part 'task.g.dart'; 8 | 9 | @Riverpod(dependencies: [userDatabase]) 10 | Stream> tasks(TasksRef ref) { 11 | final database = ref.watch(userDatabaseProvider); 12 | return database 13 | .tasksReference() 14 | .orderBy('serverCreatedDateTime', descending: true) 15 | .snapshots() 16 | .map((event) => event.docs.map((doc) => doc.data()).toList()); 17 | } 18 | 19 | @Riverpod(dependencies: [userDatabase]) 20 | Stream task(TaskRef ref, {required String taskID}) { 21 | final database = ref.watch(userDatabaseProvider); 22 | return database.taskReference(taskID: taskID).snapshots().map((event) => event.data()!); 23 | } 24 | 25 | class TaskCreate { 26 | final UserDatabase database; 27 | 28 | TaskCreate({required this.database}); 29 | 30 | Future call({required String question}) async { 31 | final docRef = database.tasksReference().doc(); 32 | final task = TaskPreparing( 33 | id: docRef.id, 34 | userID: database.userID, 35 | question: question, 36 | ); 37 | await database.taskReference(taskID: task.id).set(task, SetOptions(merge: true)); 38 | return task; 39 | } 40 | } 41 | 42 | @Riverpod(dependencies: [userDatabase]) 43 | TaskCreate taskCreate(TaskCreateRef ref) { 44 | final database = ref.watch(userDatabaseProvider); 45 | return TaskCreate(database: database); 46 | } 47 | 48 | class TaskDelete { 49 | final UserDatabase database; 50 | 51 | TaskDelete({required this.database}); 52 | 53 | Future call({required String taskID}) { 54 | return database.taskReference(taskID: taskID).delete(); 55 | } 56 | } 57 | 58 | @Riverpod(dependencies: [userDatabase]) 59 | TaskDelete taskDelete(TaskDeleteRef ref) { 60 | final database = ref.watch(userDatabaseProvider); 61 | return TaskDelete(database: database); 62 | } 63 | 64 | class TaskComplete { 65 | final UserDatabase database; 66 | 67 | TaskComplete({required this.database}); 68 | 69 | Future call({required String taskID}) { 70 | return database.taskReference(taskID: taskID).update({'completedDateTime': DateTime.now()}); 71 | } 72 | } 73 | 74 | @Riverpod(dependencies: [userDatabase]) 75 | TaskComplete taskComplete(TaskCompleteRef ref) { 76 | final database = ref.watch(userDatabaseProvider); 77 | return TaskComplete(database: database); 78 | } 79 | 80 | class TaskRevertComplete { 81 | final UserDatabase database; 82 | 83 | TaskRevertComplete({required this.database}); 84 | 85 | Future call({required String taskID}) { 86 | return database.taskReference(taskID: taskID).update({'completedDateTime': null}); 87 | } 88 | } 89 | 90 | @Riverpod(dependencies: [userDatabase]) 91 | TaskRevertComplete taskRevertComplete(TaskRevertCompleteRef ref) { 92 | final database = ref.watch(userDatabaseProvider); 93 | return TaskRevertComplete(database: database); 94 | } 95 | 96 | class TaskFillLocation { 97 | final UserDatabase database; 98 | 99 | TaskFillLocation({required this.database}); 100 | 101 | Future call({required String taskID, required LocationFormInfo userLocation}) { 102 | return database.taskReference(taskID: taskID).update( 103 | { 104 | 'userLocation': userLocation.toJson(), 105 | 'locations': null, 106 | 'locationsAITextResponse': null, 107 | 'locationsGroundings': null, 108 | }, 109 | ); 110 | } 111 | } 112 | 113 | @Riverpod(dependencies: [userDatabase]) 114 | TaskFillLocation taskFillLocation(TaskFillLocationRef ref) { 115 | final database = ref.watch(userDatabaseProvider); 116 | return TaskFillLocation(database: database); 117 | } 118 | -------------------------------------------------------------------------------- /lib/style/button.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:todomaker/style/color.dart'; 3 | 4 | ButtonStyle get filledWidthButtonStyle => ButtonStyle( 5 | minimumSize: WidgetStateProperty.all(const Size(double.infinity, 48.0)), 6 | ); 7 | 8 | ButtonStyle capsuleTextButtonStyle(BuildContext context) { 9 | final foregroundColor = Theme.of(context).colorScheme.primary; 10 | return TextButton.styleFrom( 11 | foregroundColor: foregroundColor, 12 | backgroundColor: Colors.white, 13 | padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 12.0), 14 | shape: const StadiumBorder(), 15 | elevation: 1, 16 | ); 17 | } 18 | 19 | ButtonStyle alertButtonStyle(BuildContext context) { 20 | final foregroundColor = Theme.of(context).colorScheme.primary; 21 | return TextButton.styleFrom( 22 | foregroundColor: foregroundColor, 23 | disabledForegroundColor: TextColor.disabled(TextColor.gray), 24 | backgroundColor: Colors.white, 25 | padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 12.0), 26 | shape: const StadiumBorder(), 27 | textStyle: const TextStyle( 28 | fontWeight: FontWeight.w600, 29 | fontSize: 14, 30 | ), 31 | elevation: 1, 32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /lib/style/color.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | abstract class AppColors { 4 | static const Color primary = Color(0xFF4072B3); 5 | static const Color secondary = Color(0xFF6088C6); 6 | static const Color tertiary = Color(0xFF80A4D9); 7 | static const Color accent = Color(0xFFEB8686); 8 | static const Color background = Color(0xFFFAFAFA); 9 | static const Color formBackground = Color(0xFFC0C0C0); 10 | static const Color border = Color(0xFFE0E0E0); 11 | static const Color disabled = Color(0xFFE0E0E0); 12 | 13 | static const Color sunday = Color(0xFFE17F7F); 14 | static const Color saturday = Color(0xFF7FB9E1); 15 | static const Color weekday = Color(0xFF7E7E7E); 16 | } 17 | 18 | abstract class TextColor { 19 | static const Color main = AppColors.primary; 20 | static const Color darkGray = Color(0xFF404040); 21 | static const Color gray = Color(0xFF928484); 22 | static const Color black = Colors.black; 23 | static const Color white = Colors.white; 24 | static const Color link = Colors.blueAccent; 25 | static const Color secondaryLink = darkGray; 26 | static const Color danger = Color(0xFFB00020); 27 | static const Color discount = Color(0xFFB00020); 28 | 29 | static Color highEmphasis(Color color) => color.withOpacity(0.87); 30 | static Color mediumEmphasis(Color color) => color.withOpacity(0.6); 31 | static Color disabled(Color color) => color.withOpacity(0.37); 32 | } 33 | -------------------------------------------------------------------------------- /lib/utils/analytics/analytics.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:firebase_analytics/firebase_analytics.dart'; 4 | import 'package:flutter/foundation.dart'; 5 | 6 | final firebaseAnalytics = FirebaseAnalytics.instance; 7 | 8 | var analyticsDebugIsEnabled = false; 9 | 10 | class Analytics { 11 | void debug({required String name, Map? parameters}) async { 12 | if (analyticsDebugIsEnabled || kDebugMode) { 13 | logEvent(name: name, parameters: parameters); 14 | } 15 | } 16 | 17 | void logEvent({required String name, Map? parameters}) async { 18 | assert(name.length <= 40, 'firebase analytics log event name limit length up to 40'); 19 | if (kDebugMode) { 20 | print('[INFO] logEvent name: $name, parameters: $parameters'); 21 | } 22 | 23 | if (parameters != null) { 24 | for (final key in parameters.keys) { 25 | final param = parameters[key]; 26 | if (param is DateTime) { 27 | parameters[key] = param.toIso8601String(); 28 | } 29 | if (param is bool) { 30 | parameters[key] = param ? 'true' : 'false'; 31 | } 32 | } 33 | } 34 | try { 35 | await firebaseAnalytics.logEvent(name: name, parameters: parameters); 36 | } catch (e) { 37 | debugPrint('analytics error: $e'); 38 | } 39 | } 40 | 41 | void setCurrentScreen({required String screenName, String screenClassOverride = 'Flutter'}) async { 42 | unawaited(firebaseAnalytics.logEvent(name: 'screen_$screenName')); 43 | return firebaseAnalytics.logScreenView(screenName: screenName); 44 | } 45 | 46 | /// Up to 25 user property names are supported. 47 | // The "firebase_" prefix is reserved and should not be used for 48 | /// user property names. 49 | void setUserProperties(String name, value) { 50 | assert(name.toLowerCase() != 'age'); 51 | assert(name.toLowerCase() != 'gender'); 52 | assert(name.toLowerCase() != 'interest'); 53 | assert(name.length < 25, 'firebase setUserProperties name limit length up to 25'); 54 | assert(!name.startsWith('firebase_')); 55 | 56 | if (kDebugMode) { 57 | print('[INFO] setUserProperties name: $name, value: $value'); 58 | } 59 | firebaseAnalytics.setUserProperty(name: name, value: value); 60 | } 61 | } 62 | 63 | Analytics analytics = Analytics(); 64 | -------------------------------------------------------------------------------- /lib/utils/analytics/error.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:firebase_crashlytics/firebase_crashlytics.dart'; 4 | 5 | ErrorLogger errorLogger = ErrorLogger(); 6 | 7 | class ErrorLogger { 8 | void recordError( 9 | dynamic exception, 10 | StackTrace? stack, 11 | ) { 12 | unawaited(FirebaseCrashlytics.instance.recordError(exception, stack)); 13 | } 14 | 15 | void log(String message) { 16 | unawaited(FirebaseCrashlytics.instance.log(message)); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /lib/utils/config/platform.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | 3 | String get storeName { 4 | return Platform.isIOS ? 'App Store' : 'Google Play'; 5 | } 6 | 7 | String get accountName { 8 | return Platform.isIOS ? 'Apple ID' : 'Google アカウント'; 9 | } 10 | 11 | String get forceUpdateStoreURL { 12 | return Platform.isIOS ? 'https://apps.apple.com/app/apple-store/id6741352387?pt=97327896&ct=force_update&mt=8' : ''; 13 | } 14 | -------------------------------------------------------------------------------- /lib/utils/config/remote_config.dart: -------------------------------------------------------------------------------- 1 | import 'package:firebase_remote_config/firebase_remote_config.dart'; 2 | import 'package:flutter/widgets.dart'; 3 | import 'package:todomaker/entity/remote_config_parameter.dart'; 4 | import 'package:todomaker/utils/analytics/error.dart'; 5 | 6 | final remoteConfig = FirebaseRemoteConfig.instance; 7 | 8 | Future setupRemoteConfig() async { 9 | try { 10 | await ( 11 | remoteConfig.setConfigSettings(RemoteConfigSettings( 12 | fetchTimeout: const Duration(minutes: 1), 13 | minimumFetchInterval: const Duration(hours: 1), 14 | )), 15 | remoteConfig.setDefaults({ 16 | RemoteConfigKeys.minimumAppVersion: RemoteConfigParameterDefaultValues.minimumAppVersion, 17 | }), 18 | remoteConfig.fetchAndActivate() 19 | ).wait; 20 | 21 | debugPrintRemoteConfig(); 22 | 23 | remoteConfig.onConfigUpdated.listen((event) { 24 | remoteConfig.activate(); 25 | }); 26 | } catch (error, st) { 27 | // ignore error 28 | // ParallelWaitErrorとentrypointでRemoteConfigを導入してからエラーが出るようになった。RemoteConfigの設定は失敗しても最悪どうにかなるだろう。ということでエラーは無視する 29 | debugPrint(error.toString()); 30 | errorLogger.recordError(error, st); 31 | } 32 | } 33 | 34 | void debugPrintRemoteConfig() { 35 | for (final entry in remoteConfig.getAll().entries) { 36 | debugPrint('RemoteConfig: ${entry.key} ${entry.value.asString()}'); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /lib/utils/config/version.dart: -------------------------------------------------------------------------------- 1 | import 'package:package_info_plus/package_info_plus.dart'; 2 | import 'package:todomaker/utils/platform/environment.dart'; 3 | 4 | class Version { 5 | final int major; 6 | final int minor; 7 | final int patch; 8 | 9 | Version({ 10 | required this.major, 11 | required this.minor, 12 | required this.patch, 13 | }); 14 | 15 | factory Version.parse(String str) { 16 | final splited = str.split('.'); 17 | if (Environment.isDevelopment) { 18 | assert(splited.length <= 3 || (splited.last == 'dev' && splited.length == 4), 'unexpected version format $str'); 19 | } 20 | 21 | final versions = List.filled(3, 0); 22 | for (int i = 0; i < splited.length; i++) { 23 | if (splited[i] == 'dev') { 24 | break; 25 | } 26 | versions[i] = int.parse(splited[i]); 27 | } 28 | return Version( 29 | major: versions[0], 30 | minor: versions[1], 31 | patch: versions[2], 32 | ); 33 | } 34 | 35 | static Future fromPackage() async { 36 | final package = await PackageInfo.fromPlatform(); 37 | return Version.parse(package.version); 38 | } 39 | 40 | bool isLessThan(Version other) => 41 | major < other.major || (major <= other.major && minor < other.minor) || (major <= other.major && minor <= other.minor && patch < other.patch); 42 | bool isGreaterThan(Version other) => 43 | major > other.major || (major >= other.major && minor > other.minor) || (major >= other.major && minor >= other.minor && patch > other.patch); 44 | 45 | String get version { 46 | return '$major.$minor.$patch'; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /lib/utils/date_time/date_time_ext.dart: -------------------------------------------------------------------------------- 1 | import 'package:todomaker/utils/date_time/weekday.dart'; 2 | 3 | // dateTime.addDays(n) だと n * 24 * 60 * 59 * 1000 が足されるので、サマータイムの国ではずれる 4 | extension DateTimeAdd on DateTime { 5 | DateTime addDays(int offset) { 6 | return DateTime( 7 | year, 8 | month, 9 | day + offset, 10 | hour, 11 | minute, 12 | second, 13 | millisecond, 14 | microsecond, 15 | ); 16 | } 17 | } 18 | 19 | extension Date on DateTime { 20 | DateTime date() { 21 | return DateTime(year, month, day); 22 | } 23 | } 24 | 25 | bool isSameDay(DateTime lhs, DateTime rhs) => lhs.year == rhs.year && lhs.month == rhs.month && lhs.day == rhs.day; 26 | 27 | bool isSameMonth(DateTime lhs, DateTime rhs) => lhs.year == rhs.year && lhs.month == rhs.month; 28 | 29 | extension DateTimeMonth on DateTime { 30 | bool isPreviousMonth(DateTime date) { 31 | if (isSameMonth(date, this)) { 32 | return false; 33 | } 34 | return isBefore(date); 35 | } 36 | } 37 | 38 | DateTime today() { 39 | return DateTime.now().date(); 40 | } 41 | 42 | DateTime now() { 43 | return DateTime.now(); 44 | } 45 | 46 | // Reference: https://stackoverflow.com/questions/52713115/flutter-find-the-number-of-days-between-two-dates/67679455#67679455 47 | // 同じ日だと0を返す 48 | int daysBetween(DateTime from, DateTime to) { 49 | from = DateTime(from.year, from.month, from.day); 50 | to = DateTime(to.year, to.month, to.day); 51 | return (to.difference(from).inHours / 24).round(); 52 | } 53 | 54 | DateTime firstDayOfWeekday(DateTime day) { 55 | return day.subtract(Duration(days: day.weekday == 7 ? 0 : day.weekday)); 56 | } 57 | 58 | DateTime endDayOfWeekday(DateTime day) { 59 | return day.subtract(Duration(days: day.weekday == 7 ? 0 : day.weekday)).addDays(Weekday.values.length - 1); 60 | } 61 | -------------------------------------------------------------------------------- /lib/utils/date_time/date_time_range_ext.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:todomaker/utils/date_time/date_time_ext.dart'; 3 | 4 | extension DateTimeRangeExtension on DateTimeRange { 5 | bool containsInDay(DateTime date) { 6 | final isBefore = start.isBefore(date) || isSameDay(start, date); 7 | final isAfter = end.isAfter(date) || isSameDay(end, date); 8 | return isBefore && isAfter; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /lib/utils/date_time/weekday.dart: -------------------------------------------------------------------------------- 1 | // ignore_for_file: constant_identifier_names 2 | 3 | import 'dart:io'; 4 | 5 | import 'package:flutter/material.dart'; 6 | import 'package:freezed_annotation/freezed_annotation.dart'; 7 | import 'package:intl/intl.dart'; 8 | import 'package:todomaker/style/color.dart'; 9 | 10 | enum Weekday { 11 | @JsonKey(name: 'Sunday') 12 | Sunday, 13 | @JsonKey(name: 'Monday') 14 | Monday, 15 | @JsonKey(name: 'Tuesday') 16 | Tuesday, 17 | @JsonKey(name: 'Wednesday') 18 | Wednesday, 19 | @JsonKey(name: 'Thursday') 20 | Thursday, 21 | @JsonKey(name: 'Friday') 22 | Friday, 23 | @JsonKey(name: 'Saturday') 24 | Saturday, 25 | } 26 | 27 | extension WeekdayFunctions on Weekday { 28 | static Weekday weekdayFromDate(DateTime date) { 29 | var weekdayIndex = date.weekday; 30 | var weekdays = Weekday.values; 31 | var sunday = weekdays.first; 32 | weekdays = weekdays.sublist(1) 33 | ..addAll(weekdays.sublist(0, weekdays.length)) 34 | ..insert(0, sunday); 35 | return weekdays[weekdayIndex]; 36 | } 37 | 38 | static List weekdaysForFirstWeekday(Weekday firstWeekday) { 39 | return Weekday.values.sublist(firstWeekday.index)..addAll(Weekday.values.sublist(0, firstWeekday.index)); 40 | } 41 | 42 | static List weekdays() { 43 | return DateFormat.EEEE(Platform.localeName).dateSymbols.WEEKDAYS; 44 | } 45 | 46 | String weekdayString() { 47 | return weekdays()[index]; 48 | } 49 | 50 | // [日月火水木金土] 51 | static List shortWeekdays() { 52 | return DateFormat.E(Platform.localeName).dateSymbols.SHORTWEEKDAYS; 53 | } 54 | 55 | String weekdayShortString() { 56 | return shortWeekdays()[index]; 57 | } 58 | 59 | Color weekdayColor() { 60 | switch (this) { 61 | case Weekday.Sunday: 62 | return AppColors.sunday; 63 | case Weekday.Monday: 64 | return AppColors.weekday; 65 | case Weekday.Tuesday: 66 | return AppColors.weekday; 67 | case Weekday.Wednesday: 68 | return AppColors.weekday; 69 | case Weekday.Thursday: 70 | return AppColors.weekday; 71 | case Weekday.Friday: 72 | return AppColors.weekday; 73 | case Weekday.Saturday: 74 | return AppColors.saturday; 75 | default: 76 | throw ArgumentError.notNull(''); 77 | } 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /lib/utils/format/time_of_day.dart: -------------------------------------------------------------------------------- 1 | import 'package:todomaker/utils/picker/time.dart'; 2 | 3 | class TimeOfDayFormatter { 4 | static String format(AppTimeOfDay timeOfDay) { 5 | final hour = timeOfDay.hour; 6 | final minute = timeOfDay.minute; 7 | final String hourString; 8 | final String minuteString; 9 | if (hour < 10) { 10 | hourString = '0$hour'; 11 | } else { 12 | hourString = hour.toString(); 13 | } 14 | if (minute < 10) { 15 | minuteString = '0$minute'; 16 | } else { 17 | minuteString = minute.toString(); 18 | } 19 | return '$hourString:$minuteString'; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /lib/utils/functions/firebase_functions.dart: -------------------------------------------------------------------------------- 1 | import 'package:cloud_functions/cloud_functions.dart'; 2 | import 'package:firebase_auth/firebase_auth.dart'; 3 | import 'package:flutter/material.dart'; 4 | import 'package:todomaker/entity/location_form.dart'; 5 | 6 | // GenKitがus-central1のサポートになる 7 | final functions = FirebaseFunctions.instanceFor(region: 'us-central1'); 8 | 9 | extension FirebaseFunctionsExt on FirebaseFunctions { 10 | Future enqueueTaskCreate({ 11 | required String taskID, 12 | required String question, 13 | }) async { 14 | // { 15 | // "question": "", 16 | // "userRequest": { 17 | // "userID": "", 18 | // } 19 | // } 20 | final result = await httpsCallable('enqueueTaskCreate').call( 21 | { 22 | 'taskID': taskID, 23 | 'question': question, 24 | 'userRequest': { 25 | 'userID': FirebaseAuth.instance.currentUser?.uid, 26 | }, 27 | }, 28 | ); 29 | 30 | final response = mapToJSON(result.data); 31 | if (response['result'] != 'OK') { 32 | throw Exception(response['error']['message']); 33 | } 34 | return; 35 | } 36 | 37 | Future fillLocation({ 38 | required String taskID, 39 | required LocationFormInfo userLocation, 40 | }) async { 41 | final result = await httpsCallable('enqueueFillLocation').call( 42 | { 43 | 'taskID': taskID, 44 | 'userLocation': userLocation.toJson(), 45 | 'userRequest': { 46 | 'userID': FirebaseAuth.instance.currentUser?.uid, 47 | }, 48 | }, 49 | ); 50 | final response = mapToJSON(result.data); 51 | debugPrint('enqueueFillLocation response: ${response.toString()}'); 52 | if (response['result'] != 'OK') { 53 | throw Exception(response['error']['message']); 54 | } 55 | 56 | return; 57 | } 58 | 59 | Future fillTodoLocation({ 60 | required String taskID, 61 | required String todoID, 62 | required LocationFormInfo userLocation, 63 | }) async { 64 | final result = await httpsCallable('enqueueFillTODOLocation').call( 65 | { 66 | 'taskID': taskID, 67 | 'todoID': todoID, 68 | 'userLocation': userLocation.toJson(), 69 | 'userRequest': { 70 | 'userID': FirebaseAuth.instance.currentUser?.uid, 71 | }, 72 | }, 73 | ); 74 | final response = mapToJSON(result.data); 75 | debugPrint('enqueueFillTODOLocation response: ${response.toString()}'); 76 | if (response['result'] != 'OK') { 77 | throw Exception(response['error']['message']); 78 | } 79 | 80 | return; 81 | } 82 | } 83 | 84 | // Map.fromだけだとネストした子要素が_Mapのままになる 85 | // 以下のエラーを回避する _TypeError (type '_Map' is not a subtype of type 'Map' in type cast) 86 | Map mapToJSON(Map map) { 87 | for (var key in map.keys) { 88 | if (map[key] is Map) { 89 | map[key] = mapToJSON(map[key]); 90 | } else if (map[key] is List) { 91 | map[key] = map[key].map((e) { 92 | if (e is Map) { 93 | return mapToJSON(e); 94 | } 95 | return e; 96 | }).toList(); 97 | } 98 | } 99 | return Map.from(map); 100 | } 101 | -------------------------------------------------------------------------------- /lib/utils/image/image.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | import 'dart:io'; 3 | import 'dart:typed_data'; 4 | 5 | import 'package:flutter/material.dart'; 6 | import 'package:image/image.dart'; 7 | 8 | Future convertToJPEGImage(File file) async { 9 | //1. Decode the image. 10 | final image = decodeImage(await file.readAsBytes()); 11 | if (image == null) { 12 | throw Exception('Failed to decode image'); 13 | } 14 | final resizedImageBytes = encodeJpg(image, quality: 100); 15 | return resizedImageBytes; 16 | } 17 | 18 | Future base64CompressImage(File file) async { 19 | try { 20 | //1. Decode the image. 21 | final image = decodeImage(await file.readAsBytes()); 22 | 23 | if (image == null) { 24 | throw Exception('Failed to decode image'); 25 | } 26 | 27 | // 2. Resize the image (adjust dimensions as needed) 28 | // Get image dimensions 29 | const maxSize = 1000; 30 | final width = image.width; 31 | final height = image.height; 32 | 33 | // Calculate scaling factor to maintain aspect ratio 34 | double scaleFactor; 35 | if (width > height) { 36 | scaleFactor = maxSize / width; 37 | } else { 38 | scaleFactor = maxSize / height; 39 | } 40 | 41 | // Resize the image (adjust dimensions as needed) 42 | final resizedImage = copyResize( 43 | image, 44 | width: (width * scaleFactor).round(), 45 | height: (height * scaleFactor).round(), 46 | ); 47 | 48 | // 3. Encode the resized image to base64 49 | final resizedImageBytes = encodeJpg(resizedImage, quality: 100); //or img.encodeJpg(resizedImage, quality: 85); 50 | final base64Image = base64Encode(resizedImageBytes); 51 | 52 | return base64Image; 53 | } catch (e) { 54 | debugPrint('Error resizing image: $e'); 55 | throw Exception('Error resizing image: $e'); 56 | } 57 | } 58 | 59 | String mimeType(File file) { 60 | final extension = file.path.split('.').lastOrNull; 61 | switch (extension) { 62 | case 'jpg': 63 | case 'jpeg': 64 | return 'image/jpeg'; 65 | case 'png': 66 | return 'image/png'; 67 | default: 68 | return 'image/jpeg'; // デフォルト 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /lib/utils/log/debug_print.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/foundation.dart'; 2 | 3 | void overrideDebugPrint() { 4 | debugPrint = _debugPrint; 5 | } 6 | 7 | void _debugPrint(String? message, {int? wrapWidth}) { 8 | if (kDebugMode) { 9 | if (message == null) { 10 | print('null'); 11 | } else { 12 | final pattern = RegExp('.{1,800}'); // 800 is the size of each chunk 13 | // ignore: avoid_print 14 | pattern.allMatches(message).forEach((match) => print('[APP:DEBUG] ${match.group(0)}')); 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /lib/utils/native/method_channel.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/services.dart'; 2 | 3 | const methodChannel = MethodChannel('method.channel.bannzai.medicalarm'); 4 | 5 | void requestAppTrackingTransparency() { 6 | methodChannel.invokeMethod('requestAppTrackingTransparency'); 7 | } 8 | -------------------------------------------------------------------------------- /lib/utils/native/text_field_context_menu.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | EditableTextContextMenuBuilder get appEditableTextContextMenuBuilder => (BuildContext context, EditableTextState editableTextState) { 4 | // 可能な場合はシステムコンテキストメニューを利用する 5 | if (SystemContextMenu.isSupported(context)) { 6 | return SystemContextMenu.editableText( 7 | editableTextState: editableTextState, 8 | ); 9 | } 10 | 11 | // flutter-rendered なコンテキストメニューを利用する 12 | return AdaptiveTextSelectionToolbar.editableText( 13 | editableTextState: editableTextState, 14 | ); 15 | }; 16 | -------------------------------------------------------------------------------- /lib/utils/picker/time.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter/cupertino.dart'; 3 | import 'package:flutter/rendering.dart'; 4 | import 'package:todomaker/utils/picker/toolbar.dart'; 5 | 6 | class AppTimeOfDay { 7 | final int hour; 8 | final int minute; 9 | 10 | AppTimeOfDay({required this.hour, required this.minute}); 11 | 12 | static AppTimeOfDay fromSeconds(int seconds) { 13 | final hour = seconds ~/ 3600; 14 | final minute = (seconds % 3600) ~/ 60; 15 | return AppTimeOfDay(hour: hour, minute: minute); 16 | } 17 | 18 | int get seconds { 19 | return hour * 3600 + minute * 60; 20 | } 21 | } 22 | 23 | class AppTimePicker extends StatelessWidget { 24 | final AppTimeOfDay initialTime; 25 | 26 | const AppTimePicker({ 27 | super.key, 28 | required this.initialTime, 29 | }); 30 | 31 | @override 32 | Widget build(BuildContext context) { 33 | var hour = initialTime.hour; 34 | var minute = initialTime.minute; 35 | 36 | return Column( 37 | mainAxisAlignment: MainAxisAlignment.end, 38 | mainAxisSize: MainAxisSize.min, 39 | children: [ 40 | PickerToolbar( 41 | done: (() { 42 | Navigator.pop(context, AppTimeOfDay(hour: hour, minute: minute)); 43 | }), 44 | cancel: (() => Navigator.pop(context, null)), 45 | ), 46 | SizedBox( 47 | height: MediaQuery.of(context).size.height / 3, 48 | width: MediaQuery.of(context).size.width, 49 | child: Row( 50 | mainAxisSize: MainAxisSize.min, 51 | children: [ 52 | Expanded( 53 | child: GestureDetector( 54 | onTap: () { 55 | Navigator.pop(context); 56 | }, 57 | child: CupertinoPicker( 58 | itemExtent: 30, 59 | scrollController: FixedExtentScrollController( 60 | initialItem: hour, 61 | ), 62 | onSelectedItemChanged: (int value) { 63 | hour = value; 64 | }, 65 | children: List.generate(999, (index) => Text('$index時')), 66 | )), 67 | ), 68 | Expanded( 69 | child: GestureDetector( 70 | onTap: () { 71 | Navigator.pop(context); 72 | }, 73 | child: CupertinoPicker( 74 | itemExtent: 30, 75 | scrollController: FixedExtentScrollController( 76 | initialItem: minute, 77 | ), 78 | onSelectedItemChanged: (int value) { 79 | minute = value; 80 | }, 81 | children: List.generate(60, (index) => Text('$index分')), 82 | ), 83 | ), 84 | ), 85 | ], 86 | ), 87 | ), 88 | ], 89 | ); 90 | } 91 | } 92 | 93 | Future showAppTimePicker(BuildContext context, {required AppTimeOfDay initialTime}) { 94 | return showModalBottomSheet( 95 | useSafeArea: true, 96 | context: context, 97 | builder: (BuildContext context) { 98 | return AppTimePicker(initialTime: initialTime); 99 | }, 100 | ); 101 | } 102 | -------------------------------------------------------------------------------- /lib/utils/picker/toolbar.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/cupertino.dart'; 2 | 3 | class PickerToolbar extends StatelessWidget { 4 | final VoidCallback done; 5 | final VoidCallback cancel; 6 | 7 | const PickerToolbar({super.key, required this.done, required this.cancel}); 8 | @override 9 | Widget build(BuildContext context) { 10 | return SizedBox( 11 | height: 44, 12 | child: Row( 13 | mainAxisAlignment: MainAxisAlignment.spaceBetween, 14 | children: [ 15 | CupertinoButton( 16 | onPressed: () { 17 | cancel(); 18 | }, 19 | padding: const EdgeInsets.symmetric( 20 | horizontal: 16.0, 21 | vertical: 5.0, 22 | ), 23 | child: const Text( 24 | 'キャンセル', 25 | style: TextStyle( 26 | fontWeight: FontWeight.w300, 27 | fontSize: 14, 28 | ), 29 | ), 30 | ), 31 | CupertinoButton( 32 | onPressed: () { 33 | done(); 34 | }, 35 | padding: const EdgeInsets.symmetric( 36 | horizontal: 16.0, 37 | vertical: 5.0, 38 | ), 39 | child: const Text( 40 | '完了', 41 | style: TextStyle( 42 | fontWeight: FontWeight.w600, 43 | fontSize: 16, 44 | ), 45 | ), 46 | ) 47 | ], 48 | ), 49 | ); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /lib/utils/platform/environment.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/foundation.dart'; 2 | 3 | abstract class Environment { 4 | static bool get isProduction { 5 | return kReleaseMode; 6 | } 7 | 8 | static bool get isDevelopment { 9 | return kDebugMode; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /lib/utils/shared_preferences/keys.dart: -------------------------------------------------------------------------------- 1 | extension BoolKey on String {} 2 | 3 | extension StringKey on String { 4 | static const String lastSignInFirebaseAuthUserID = 'lastSignInFirebaseAuthUserID'; 5 | } 6 | 7 | extension ReleaseNoteKey on String {} 8 | 9 | extension IntKey on String { 10 | static const String totalRecordActionCount = 'totalRecordActionCount'; 11 | } 12 | -------------------------------------------------------------------------------- /lib/utils/storage/firebase_cloud_storage.dart: -------------------------------------------------------------------------------- 1 | import 'dart:typed_data'; 2 | 3 | import 'package:firebase_storage/firebase_storage.dart'; 4 | import 'package:uuid/uuid.dart'; 5 | 6 | final storage = FirebaseStorage.instance; 7 | final rootRef = storage.ref(); 8 | 9 | Reference userRef = rootRef.child('users'); 10 | Reference medicinesRef({required String userID}) => userRef.child(userID).child('medicines'); 11 | 12 | Future uploadImage(Reference ref, Uint8List bytes) async { 13 | final uuid = const Uuid().v4(); 14 | final fileRef = ref.child(uuid); 15 | final uploadTask = fileRef.putData(bytes); 16 | final snapshot = await uploadTask.whenComplete(() => null); 17 | final url = await snapshot.ref.getDownloadURL(); 18 | return url; 19 | } 20 | -------------------------------------------------------------------------------- /test/widget_test.dart: -------------------------------------------------------------------------------- 1 | // This is a basic Flutter widget test. 2 | // 3 | // To perform an interaction with a widget in your test, use the WidgetTester 4 | // utility in the flutter_test package. For example, you can send tap and scroll 5 | // gestures. You can also use WidgetTester to find child widgets in the widget 6 | // tree, read text, and verify that the values of widget properties are correct. 7 | 8 | import 'package:flutter/material.dart'; 9 | import 'package:flutter_test/flutter_test.dart'; 10 | import 'package:todomaker/app.dart'; 11 | 12 | void main() { 13 | testWidgets('Counter increments smoke test', (WidgetTester tester) async { 14 | // Build our app and trigger a frame. 15 | await tester.pumpWidget(const App()); 16 | 17 | // Verify that our counter starts at 0. 18 | expect(find.text('0'), findsOneWidget); 19 | expect(find.text('1'), findsNothing); 20 | 21 | // Tap the '+' icon and trigger a frame. 22 | await tester.tap(find.byIcon(Icons.add)); 23 | await tester.pump(); 24 | 25 | // Verify that our counter has incremented. 26 | expect(find.text('0'), findsNothing); 27 | expect(find.text('1'), findsOneWidget); 28 | }); 29 | } 30 | --------------------------------------------------------------------------------