diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a02f9fd --- /dev/null +++ b/.gitignore @@ -0,0 +1,40 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock. +/pubspec.lock +**/doc/api/ +.dart_tool/ +build/ +example/.env +example/lib/firebase_options.dart +example/firebase.json +example/.firebaserc +example/android/app/google-services.json +example/ios/Runner/GoogleService-Info.plist +example/macos/Runner/GoogleService-Info.plist +example/lib/echo.dart +.flutter-plugins +.flutter-plugins-dependencies +.vscode/settings.json diff --git a/.metadata b/.metadata new file mode 100644 index 0000000..8f7b6ac --- /dev/null +++ b/.metadata @@ -0,0 +1,10 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "761747bfc538b5af34aa0d3fac380f1bc331ec49" + channel: "stable" + +project_type: package diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..059bd32 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,89 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "main", + "cwd": "example", + "request": "launch", + "type": "dart", + "program": "lib/main.dart", + }, + { + "name": "gemini", + "cwd": "example", + "request": "launch", + "type": "dart", + "program": "lib/gemini/gemini.dart", + }, + { + "name": "vertex", + "cwd": "example", + "request": "launch", + "type": "dart", + "program": "lib/vertex/vertex.dart", + }, + { + "name": "demo", + "cwd": "example", + "request": "launch", + "type": "dart", + "program": "lib/demo/demo.dart", + }, + { + "name": "welcome", + "cwd": "example", + "request": "launch", + "type": "dart", + "program": "lib/welcome/welcome.dart", + }, + { + "name": "cupertino", + "cwd": "example", + "request": "launch", + "type": "dart", + "program": "lib/cupertino/cupertino.dart", + }, + { + "name": "custom styles", + "cwd": "example", + "request": "launch", + "type": "dart", + "program": "lib/custom_styles/custom_styles.dart", + }, + { + "name": "dark mode", + "cwd": "example", + "request": "launch", + "type": "dart", + "program": "lib/dark_mode/dark_mode.dart", + }, + { + "name": "history", + "cwd": "example", + "request": "launch", + "type": "dart", + "program": "lib/history/history.dart", + }, + { + "name": "suggestions", + "cwd": "example", + "request": "launch", + "type": "dart", + "program": "lib/suggestions/suggestions.dart", + }, + { + "name": "logging", + "cwd": "example", + "request": "launch", + "type": "dart", + "program": "lib/logging/logging.dart", + }, + { + "name": "recipes", + "cwd": "example", + "request": "launch", + "type": "dart", + "program": "lib/recipes/recipes.dart", + }, + ] +} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..6492d94 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,162 @@ +## 0.6.5 + +* implemented #12: would like some hover icons for copy+edit on web and desktop + +* implemented #9: need to be able to cancel a prompt edit and get back the last response unharmed + +## 0.6.4 + +* fixed #62: bug: getting an image back from the LLM that doesn't exist throws an exception + +* expanded the `messageSender` docs on `LlmChatView` to make it clear what it's for and when it's used + +* renamed FatXxx to ToolkitXxx e.g. FatColors => ToolkitColors + +* fixed #77: move dark theming to the samples and out of the toolkit, since it has no designer input + +## 0.6.3 + +* fixed #55: TextField overflow error for large inputs + +* fixed #39: bug: notify developer of invalid API key on the web + +* fixed #18: Gemini or Vertex + the web + a file attachment == hang + +## 0.6.2 + +* minor API and README updates based on review feedback + +## 0.6.1 + +* implemented #16: feature: ensure pressing the camera button on desktop web brings up the camera + +## 0.6.0 + +* simplifying the `LlmProvider` interface for implementors + +## 0.5.0 + +* fixed #67: bug: audio recording translation repopulated after history cleared + +* fixed #68: bug: need suggestion styling + +* implemented #72: feature: move welcome message to the LlmChatView + +* updated recipes sample to use required properties in the JSON schema, which improved LLM responses a great deal + +* implemented #74: remove controllers as an unnecessary abstraction + +* fixed an issue where canceling an operation w/ no response yet will continue showing the progress indicator. + + +## 0.4.2 + +* upgraded to waveform_recorder 1.3.0 + +* fix #69: SDK dependency conflict by lowering sdk requirement to 3.4.0 + +## 0.4.1 + +* updated samples, demo and README + +## 0.4.0 + +* implemented #13: UI needs dark mode support + +* implemented #30: chat serialization/deserialization + +* fixed #64: bug: selection sticks around after clearing the history + +* fixed #63: bug: broke multi-line text input + +* fixed #60: bug: if an LLM request fails with no text in the response, the progress indicator never stops + +* implemented #31: feature: provide a list of initial suggested prompts to display in an empty chat session + +* implemented #25: better mobile long-press action menu for chat messages + +* fixed #47: bug: Long pressing a message and then clicking outside of the toolbar should make the toolbar disappear + +## 0.3.0 + +* implemented #32: feature: dev-configured LLM message icon + +* fixed #19: prompt attachments are incorrectly merging when editing after adding attachments to a new prompt + +* implemented #27: feature: styling of colors and fonts + +## 0.2.1 + +* fixing the user message edit menu + +* show errors and cancelations as separate from message output; this is necessary so that you can tell the difference between an LLM message response with a successful result that, for example, can be parsed as JSON, or an error + +## 0.2.0 + +* implemented #33: feature: chat microphone only prompt input + +* added a `generateStream` method to `LlmProvider` to support talking to the underlying generative model w/o adding to the chat history; moved `chatModel` properties in the Gemini and Vertex providers to use a more generic `generativeModel` to make it clear which model is being used for both `sendMessageStream` and `generateStream`. + +* moved from [flutter_markdown_selectionarea](https://pub.dev/packages/flutter_markdown_selectionarea) to plain ol' [flutter_markdown](https://pub.dev/packages/flutter_markdown) which does now support selection if you ask it nicely. I still have some work to do on selection, however, as described in [issue #1212). + +* implemented #27: styling support, including a sample + +* fixed #3: ensure Google Font Roboto is being resolved + +* implemented #2: feature: enable full functionality inside a Cupertino app + +* fixed #45: bug: X icon button is also pushing up against the top and left edges without any padding + +* fixed #59: bug: Android Studio LadyBug Upgrade Issues + +* upgraded to the GA version of firebase_vertexai + +## 0.1.6 + +* added optional `welcomeMessage` to `LlmChatView` and a welcome sample. thanks, @berkaykurkcu! + +* updated VertexProvider to take a separate chat and embedding model like GeminiProvider + +* fixed #51 : Click on an image to get a preview. thanks, @Shashwat-111! + +* fixed #6: get a spark icon to designate the LLM + +* updated README for clarity + +## 0.1.5 + +* Reference docs update + +## 0.1.4 + +* CHANGELOG fix + +## 0.1.3 + +* new real-world-ish sample: recipes + +* new custom LLM provider sample: gemma + +* handling structured LLM responses via `responseBuilder` (see recipes sample) + +* app-provided prompt suggestions (see recipes sample) + +* pre-processing prompts to add prompt engineering via `messageSender` + +* pre-processing requests to enrich the output, e.g. host Flutter widgets (see recipes sample) + +* swappable support for LLM providers; oob support for Gemini and Vertex (see gemma example) + +* fixed trim and over-eager message editing issues -- thanks, @Shashwat-111! + +## 0.1.2 + +* More README fixups + +## 0.1.1 + +* Fixing README screenshot (sigh) + +## 0.1.0 + +* Initial alpha release \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..922fc0c --- /dev/null +++ b/LICENSE @@ -0,0 +1,25 @@ +Copyright 2014 The Flutter Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google Inc. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..dbcf690 --- /dev/null +++ b/README.md @@ -0,0 +1,140 @@ +Hello and welcome to the Flutter AI Toolkit! + +The AI Toolkit is a set of AI chat-related widgets to make it easy to add an AI chat window to your Flutter app. The AI Toolkit is organized around an abstract LLM provider API to make it easy to swap out the LLM provider that you'd like your chat provider to use. Out of the box, it comes with support for two LLM provider integrations: Google Gemini AI and Firebase Vertex AI. + +## Features +- multi-turn chat (remembering context along the way) +- streaming responses +- multi-line chat text input +- cancel in-progress request +- edit the last prompt +- rich text response display +- chat microphone speech-to-tech prompt input +- copy any response +- multi-media attachments +- handling structured LLM responses to show app-specific Flutter widgets +- app-provided prompt suggestions +- pre-processing prompts to add logging, prompt engineering, etc. +- custom styling support +- support for Cupertino as well as Material +- chat session serialization/deserialization +- swappable support for LLM providers; oob support for Gemini and Vertex +- support for the same Flutter platforms that Firebase supports: Android, iOS, web and macOS + +Here's [the online demo](https://flutter-ai-toolkit-examp-60bad.web.app/) hosting the AI Tookit: + + + +The [source code for this demo](https://github.com/flutter/ai/blob/main/example/lib/demo/demo.dart) is available in the repo. + +## Getting started +Using the AI Toolkit is a matter of choosing which LLM provider you'd like to use (Gemini or Vertex), creating an instance and passing it to the `LlmChatView` widget, which is the main entry point for the AI Toolkit: + +```dart +// don't forget the pubspec.yaml entries for these, too +import 'package:google_generative_ai/google_generative_ai.dart'; +import 'package:flutter_ai_toolkit/flutter_ai_toolkit.dart'; + +... // app stuff here + +class ChatPage extends StatelessWidget { + const ChatPage({super.key}); + + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar(title: const Text(App.title)), + body: LlmChatView( + provider: GeminiProvider( + model: GenerativeModel( + model: 'gemini-1.5-flash', + apiKey: geminiApiKey, + ), + ), + ), + ); +} +``` + +Here we're creating an instance of the `GeminiProvider`, configuring it as appropriate with an instance of the `GenerativeModel` from the `google_generative_ai` package and passing it to an instance of the `LlmChatView`. That yields the screenshot above using Google Gemini AI as the LLM. You can see more details about configuring both the Gemini and Vertex LLM providers below. + +## Gemini LLM Usage +To configure the `GeminiProvider` you two things: +1. a model created using a model string, which you can ready about in [the Gemini models docs](https://ai.google.dev/gemini-api/docs/models/gemini), and +2. an API key, which you can get [in Gemini AI Studio](https://aistudio.google.com/app/apikey). + +With this in place, you're ready to write the Gemini code shown above. If you like, you can plug your API key and model string into the gemini.dart sample. This sample has been tested on Android, iOS, the web and macOS, so give it a whirl. +### gemini_api_key.dart +Most of [the sample apps](https://github.com/flutter/ai/tree/main/example) reply on a Gemini API key, so for those to work, you'll need to plug your API key into a file called `gemini_api_key.dart` and put it in the `example/lib` folder (after cloning the repo, of course). Here's what it should look like: + +```dart +// example/lib/gemini_api_key.dart +const geminiApiKey = 'YOUR-API-KEY'; +``` + +Note: Be careful not to check your API key into a git repo or share it with anyone. +## Vertex LLM Usage +While Gemini AI is useful for quick prototyping, the recommended solution for production apps is Vertex AI in Firebase. And the reason for that is that there's no good way to keep your Gemini API key safe -- if you ship your Flutter app with the API key in there, someone can figure out how to dig it out. + +To solve this problem as well as many others that you're going to have in a real-world production app, the model for initializing an instance of the Vertex AI LLM provider doesn't have an API key. Instead, it relies on a Firebase project, which you then initialize in your app. You can do that with the steps described in [the Get started with the Gemini API using the Vertex AI in Firebase SDKs docs](https://firebase.google.com/docs/vertex-ai/get-started?platform=flutter). + +Also make sure you configure your FlutterApp using the `flutterfire` CLI tool as described in [the Add Firebase to your Flutter app docs](https://firebase.google.com/docs/flutter/setup). **Make sure to run this tool from within the `example` directory.** + +After following these instructions, you're ready to use Firebase Vertex AI in your project. Start by initializing Firebase: + +```dart +// don't forget the pubspec.yaml entries for these, too +import 'package:firebase_core/firebase_core.dart'; +import 'package:flutter_ai_toolkit/flutter_ai_toolkit.dart'; + +... // other imports + +import 'firebase_options.dart'; // from `flutterfire config` + +void main() async { + WidgetsFlutterBinding.ensureInitialized(); + await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform); + runApp(const App()); +} + +... // app stuff here +``` + +This is the same way that you'd initialize Firebase for use in any Flutter project, so it should be familiar to existing FlutterFire users. + +Now you're ready to create an instance of the Vertex provider: + +```dart +class ChatPage extends StatelessWidget { + const ChatPage({super.key}); + + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar(title: const Text(App.title)), + // create the chat view, passing in the Vertex provider + body: LlmChatView( + provider: VertexProvider( + chatModel: FirebaseVertexAI.instance.generativeModel( + model: 'gemini-1.5-flash', + ), + ), + ), + ); +} +``` +If you like, use your Firebase project with the vertex.dart sample. This sample is supported on Android, iOS, the web and macOS. + +Note: There's no API key; Firebase manages all of that for you in the Firebase project. However, in the same way that someone can reverse engineer the Gemini API key out of your Flutter code, they can do that with your Firebase project ID and related settings. To guard against that, check out [Firebase AppCheck](https://firebase.google.com/learn/pathways/firebase-app-check). + +## Device Access Permissions +To enable the microphone feature, configure your app according to [the record package's permission setup instructions](https://pub.dev/packages/record#setup-permissions-and-others). + +To enable the user to select a file on their device to upload to the LLM, configure your app according to [the file_selector plugin's usage instructions](https://pub.dev/packages/file_selector#usage). + +To enable the user to select an image file on their device, configure your app according to [the image_picker plugin's installation instructions](https://pub.dev/packages/image_picker#installation). + +To enable the user to take a picture on their device, configurate your app according to [the image_picker plugin's installation instructions](https://pub.dev/packages/image_picker#installation). + +To enable the user to take a picture on the web, configure your app according to [the camera plugin's setup instructions](https://pub.dev/packages/camera#setup). + +## Feedback! +Along the way, as you use this package, please [log issues and feature requests](https://github.com/flutter/ai/issues) as well as any [code you'd like to contribute](https://github.com/flutter/ai/pulls). I want your feedback and your contributions to ensure that the AI Toolkit is just as robust and useful as it can be for your real-world apps. diff --git a/README/screenshot.png b/README/screenshot.png new file mode 100644 index 0000000..206141a Binary files /dev/null and b/README/screenshot.png differ diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..10da31f --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1,4 @@ +analyzer: + errors: + library_private_types_in_public_api: ignore +include: package:flutter_lints/flutter.yaml diff --git a/deploy-demo.sh b/deploy-demo.sh new file mode 100755 index 0000000..7491465 --- /dev/null +++ b/deploy-demo.sh @@ -0,0 +1,5 @@ +cd example +rm -rf build/web +flutter build web --release --target lib/demo/demo.dart +firebase deploy +cd .. diff --git a/example/.firebase/hosting.YnVpbGQvd2Vi.cache b/example/.firebase/hosting.YnVpbGQvd2Vi.cache new file mode 100644 index 0000000..ae692e4 --- /dev/null +++ b/example/.firebase/hosting.YnVpbGQvd2Vi.cache @@ -0,0 +1,35 @@ +manifest.json,1722130836723,f81e4554dc7f05633a2c5597416813859de5ace688342db41b201d42790fb8a7 +flutter.js,1726080818000,dec659847e4e16b505a257eb15bc32ef814f99a319e44e15b9c56294de0c9ecd +favicon.png,1716580864393,fcc7c4545d5b62ad01682589e6fdc7ea03d0a3b42069963c815c344b632eb5cf +icons/Icon-maskable-512.png,1716580886213,e7983524dc70254adc61764657d7e03d19284de8da586b5818d737bc08c6d14e +icons/Icon-maskable-192.png,1716580886213,dd96c123fdf6817cdf7e63d9693bcc246bac2e3782a41a6952fa41c0617c5573 +icons/Icon-512.png,1716580864394,7a31ce91e554f1941158ca46f31c7f3f2b7c8c129229ea74a8fae1affe335033 +icons/Icon-192.png,1716580864394,d2e0131bb7851eb9d98f7885edb5ae4b4d6b7a6c7addf8a25b9b712b39274c0f +canvaskit/skwasm.worker.js,1726081188000,ff1a6b2c254c1954106d62ce10432547fa6d6a7d99c0aaf9ea6144a23ef0c09b +canvaskit/skwasm.wasm,1726081188000,204bb6c7deaa41ccfdb811d09107bcacd7162a848d8e9faa45e46390fe0dda9a +canvaskit/skwasm.js.symbols,1726081188000,ea615fdcd1320bad0f3fb6b3e5bcb0dd3a4d8480c221af6a84455860775987c1 +canvaskit/skwasm.js,1726081188000,3da3d8d5b168f8f2cba2513cba2c65a9bfa650723545145cffa76a102a678e9d +canvaskit/canvaskit.wasm,1726081100000,2e91b313aa59675be755df469bb88efdfa2be8cdccc4e49bf743f9f22d16f933 +canvaskit/canvaskit.js.symbols,1726081098000,7687fed87ac2c6f61e5d991be1e3f8c0191e4271128c73a5afea2f28562ad0b8 +canvaskit/canvaskit.js,1726081100000,95f051f3c2845fb04127fd56e1fa3e52a69d271eea561869e97bfe4374548109 +canvaskit/chromium/canvaskit.wasm,1726081122000,d865adf21902388e4d4af54a5e430479e5ef37ac660649017db1877b29976a08 +canvaskit/chromium/canvaskit.js.symbols,1726081120000,9d7b8e9cc146e9ad2b68af9f0c8092b144a9aac73666941b468b8b2fd36cdb27 +canvaskit/chromium/canvaskit.js,1726081122000,c448a9b3e29d0dad724aa33a554695e7f1257c58b28baca305177fd18e62e411 +assets/packages/record_web/assets/js/record.worklet.js,1726247519128,bd510fc16afe17c0cfc943194267c8d2af8a4b88fa63b525926c95d319e544c7 +assets/packages/record_web/assets/js/record.fixwebmduration.js,1726247519130,77e2fe77324499420d0a882273998e7574fa885c3a7d944a8e69382e5daaab08 +assets/assets/recipes_default.json,1727751782273,1f90b422a85d1e7a708fd4d255d8b7db1b6206bf002813cb380d6cce0fbb4bd8 +assets/assets/halloween-bg.png,1729875831180,aa155fdcd4249179b89ca8e6dbfbff642a622534635e9cc6dce5c099d7445b7e +version.json,1730136001205,8e7012f17ec662cfb721c69523e82a27ac216e8e3c973b248b847e9a118cf603 +index.html,1730135983527,4c1651c60b2cd671eb40ebf9e77cd7d7f1894a90b21182fa149ead727d85095f +assets/FontManifest.json,1730136001277,d1784484a5fbf123cc38d6fb83f618e2f855cfc9a47c4b87c34565736fb1025f +flutter_service_worker.js,1730136002322,09a340c85d8522734c4db9521a920665aef469eec44a7162064304be50c817ee +assets/AssetManifest.json,1730136001277,5ee990889da9f2ce04fb308d72dfb0a4df0a2a8c4cfe808dcc784e0cea9560ea +assets/AssetManifest.bin.json,1730136001277,599a0b1e65a657294c65f94df02a01c115e4f2ef9a651ce3bbadad941866dc9d +assets/AssetManifest.bin,1730136001277,71fc78fcaf72348505ddacbfc2fc03fb686e543376e18388ecdca2509e70245d +flutter_bootstrap.js,1730135983509,257003d38cfc778893260c7ce9a69c191aa5c23c05402fb287677767cc049188 +assets/packages/flutter_ai_toolkit/lib/fonts/FatIcons.ttf,1730136002032,59c80e8640bde5d1fc73af52961bb52ed3f08fddd3af8f8fe172880442cc33b7 +assets/packages/cupertino_icons/assets/CupertinoIcons.ttf,1730136002030,a9dec9e47fcee105fc5f7ea79904e588215596ef681f1ba97034cd0829c0554b +assets/shaders/ink_sparkle.frag,1730136001332,80c6e65c75f1de434b1b22dba61e96ad82dba0f2fc5e8b3b59c2def46d794354 +assets/fonts/MaterialIcons-Regular.otf,1730136002034,d1e5ecfde56f17e87cc9b0e73792a40eadf95ed0372e336e13038744c8dc09cf +assets/NOTICES,1730136001278,b7c968ec8b44aa16ccd1a0138cfee73251738e425cae9fcc8fbdca9051337fc6 +main.dart.js,1730136001025,34484a94aff99c34bb6608f95d1a7e3d39b2bd66184d520341ff826ace776120 diff --git a/example/.gitignore b/example/.gitignore new file mode 100644 index 0000000..4fc6aaa --- /dev/null +++ b/example/.gitignore @@ -0,0 +1,47 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release + +# custom +gemini_api_key.dart +firebase_options.dart diff --git a/example/.metadata b/example/.metadata new file mode 100644 index 0000000..c689480 --- /dev/null +++ b/example/.metadata @@ -0,0 +1,30 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "2663184aa79047d0a33a14a3b607954f8fdd8730" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: 2663184aa79047d0a33a14a3b607954f8fdd8730 + base_revision: 2663184aa79047d0a33a14a3b607954f8fdd8730 + - platform: android + create_revision: 2663184aa79047d0a33a14a3b607954f8fdd8730 + base_revision: 2663184aa79047d0a33a14a3b607954f8fdd8730 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/example/analysis_options.yaml b/example/analysis_options.yaml new file mode 100644 index 0000000..10da31f --- /dev/null +++ b/example/analysis_options.yaml @@ -0,0 +1,4 @@ +analyzer: + errors: + library_private_types_in_public_api: ignore +include: package:flutter_lints/flutter.yaml diff --git a/example/android/.gitignore b/example/android/.gitignore new file mode 100644 index 0000000..55afd91 --- /dev/null +++ b/example/android/.gitignore @@ -0,0 +1,13 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java + +# Remember to never publicly share your keystore. +# See https://flutter.dev/to/reference-keystore +key.properties +**/*.keystore +**/*.jks diff --git a/example/android/app/build.gradle b/example/android/app/build.gradle new file mode 100644 index 0000000..a07b54f --- /dev/null +++ b/example/android/app/build.gradle @@ -0,0 +1,47 @@ +plugins { + id "com.android.application" + // START: FlutterFire Configuration + id 'com.google.gms.google-services' + // END: FlutterFire Configuration + id "kotlin-android" + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id "dev.flutter.flutter-gradle-plugin" +} + +android { + namespace = "com.example.example" + compileSdk = flutter.compileSdkVersion + ndkVersion = "25.1.8937393" + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_1_8 + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId = "com.example.example" + // You can update the following values to match your application needs. + // For more information, see: https://flutter.dev/to/review-gradle-config. + minSdk = 23 // flutter.minSdkVersion + targetSdk = flutter.targetSdkVersion + versionCode = flutter.versionCode + versionName = flutter.versionName + } + + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig = signingConfigs.debug + } + } +} + +flutter { + source = "../.." +} diff --git a/example/android/app/src/debug/AndroidManifest.xml b/example/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/example/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/example/android/app/src/main/AndroidManifest.xml b/example/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..3090d18 --- /dev/null +++ b/example/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/example/android/app/src/main/kotlin/com/example/example/MainActivity.kt b/example/android/app/src/main/kotlin/com/example/example/MainActivity.kt new file mode 100644 index 0000000..70f8f08 --- /dev/null +++ b/example/android/app/src/main/kotlin/com/example/example/MainActivity.kt @@ -0,0 +1,5 @@ +package com.example.example + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity: FlutterActivity() diff --git a/example/android/app/src/main/res/drawable-v21/launch_background.xml b/example/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..f74085f --- /dev/null +++ b/example/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/example/android/app/src/main/res/drawable/launch_background.xml b/example/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..304732f --- /dev/null +++ b/example/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..db77bb4 Binary files /dev/null and b/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..17987b7 Binary files /dev/null and b/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..09d4391 Binary files /dev/null and b/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..d5f1c8d Binary files /dev/null and b/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..4d6372e Binary files /dev/null and b/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/example/android/app/src/main/res/values-night/styles.xml b/example/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..06952be --- /dev/null +++ b/example/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/example/android/app/src/main/res/values/styles.xml b/example/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..cb1ef88 --- /dev/null +++ b/example/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/example/android/app/src/profile/AndroidManifest.xml b/example/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/example/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/example/android/build.gradle b/example/android/build.gradle new file mode 100644 index 0000000..d2ffbff --- /dev/null +++ b/example/android/build.gradle @@ -0,0 +1,18 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +rootProject.buildDir = "../build" +subprojects { + project.buildDir = "${rootProject.buildDir}/${project.name}" +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean", Delete) { + delete rootProject.buildDir +} diff --git a/example/android/gradle.properties b/example/android/gradle.properties new file mode 100644 index 0000000..2597170 --- /dev/null +++ b/example/android/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=2G -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true +android.enableJetifier=true diff --git a/example/android/gradle/wrapper/gradle-wrapper.properties b/example/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..9c5194d --- /dev/null +++ b/example/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-all.zip \ No newline at end of file diff --git a/example/android/settings.gradle b/example/android/settings.gradle new file mode 100644 index 0000000..c818a7c --- /dev/null +++ b/example/android/settings.gradle @@ -0,0 +1,28 @@ +pluginManagement { + def flutterSdkPath = { + def properties = new Properties() + file("local.properties").withInputStream { properties.load(it) } + def flutterSdkPath = properties.getProperty("flutter.sdk") + assert flutterSdkPath != null, "flutter.sdk not set in local.properties" + return flutterSdkPath + }() + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id "dev.flutter.flutter-plugin-loader" version "1.0.0" +id "com.android.application" version "8.3.2" apply false + // START: FlutterFire Configuration + id "com.google.gms.google-services" version "4.3.15" apply false + // END: FlutterFire Configuration + id "org.jetbrains.kotlin.android" version "1.8.22" apply false +} + +include ":app" diff --git a/example/assets/README.md b/example/assets/README.md new file mode 100644 index 0000000..637991d --- /dev/null +++ b/example/assets/README.md @@ -0,0 +1,3 @@ +The following assets have been generated by the Gemini LLM and are being used royalty-free in this project: +- halloween-bg.png +- recipes_default.json \ No newline at end of file diff --git a/example/assets/halloween-bg.png b/example/assets/halloween-bg.png new file mode 100644 index 0000000..3dfbd3a Binary files /dev/null and b/example/assets/halloween-bg.png differ diff --git a/example/assets/recipes_default.json b/example/assets/recipes_default.json new file mode 100644 index 0000000..3593f51 --- /dev/null +++ b/example/assets/recipes_default.json @@ -0,0 +1,296 @@ +[ + { + "id": "f8c3de3d-1fea-4d7c-a8b0-29f63c4c3454", + "title": "Spaghetti and Meatballs", + "description": "A classic Italian comfort food dish.", + "ingredients": [ + "1 pound spaghetti", + "1 pound ground beef", + "1 onion, chopped", + "2 cloves garlic, minced", + "1 (28 ounce) can crushed tomatoes", + "1 (15 ounce) can tomato sauce", + "1/4 cup chopped fresh parsley", + "1 teaspoon dried oregano", + "1/2 teaspoon salt", + "1/4 teaspoon black pepper" + ], + "instructions": [ + "Cook spaghetti according to package directions.", + "Mix ground beef, onion, garlic, and spices; form into meatballs.", + "Brown meatballs in a large skillet over medium heat.", + "Add crushed tomatoes and tomato sauce; simmer for 20 minutes.", + "Serve meatballs and sauce over cooked spaghetti." + ], + "tags": [ + "Italian", + "pasta", + "dinner", + "comfort food" + ], + "notes": "" + }, + { + "id": "a2c8d3e3-b1f7-4d5a-9c3b-1f8e7d4c6e5d", + "title": "Chicken Stir-Fry", + "description": "A quick and easy weeknight meal.", + "ingredients": [ + "1 pound boneless, skinless chicken breasts, cut into bite-sized pieces", + "1 tablespoon vegetable oil", + "1 onion, sliced", + "1 red bell pepper, sliced", + "1 green bell pepper, sliced", + "1 (14.5 ounce) can chicken broth", + "1/4 cup soy sauce", + "2 tablespoons cornstarch", + "1/4 cup water", + "1 teaspoon sesame oil" + ], + "instructions": [ + "Heat oil in a large skillet or wok over medium-high heat.", + "Add chicken and cook until browned, about 5 minutes.", + "Add onion and bell peppers; stir-fry for 3-4 minutes.", + "Mix chicken broth, soy sauce, cornstarch, and water in a bowl.", + "Pour sauce into the skillet and cook until thickened.", + "Drizzle with sesame oil and serve over rice." + ], + "tags": [ + "Asian", + "chicken", + "quick meal", + "stir-fry" + ], + "notes": "" + }, + { + "id": "b3d9e4f5-c2g8-6h7i-j9k0-l1m2n3o4p5q6", + "title": "Pancakes", + "description": "A delicious breakfast staple.", + "ingredients": [ + "1 1/2 cups all-purpose flour", + "2 tablespoons sugar", + "2 teaspoons baking powder", + "1/2 teaspoon salt", + "1 1/4 cups milk", + "1 egg", + "3 tablespoons melted unsalted butter" + ], + "instructions": [ + "Whisk together flour, sugar, baking powder, and salt in a bowl.", + "In another bowl, whisk milk, egg, and melted butter.", + "Pour wet ingredients into dry ingredients and mix until just combined.", + "Heat a griddle or non-stick pan over medium heat.", + "Pour 1/4 cup batter for each pancake and cook until bubbles form.", + "Flip and cook other side until golden brown.", + "Serve with maple syrup and butter." + ], + "tags": [ + "breakfast", + "sweet", + "quick", + "vegetarian" + ], + "notes": "" + }, + { + "id": "c4e0f5g6-h7i8-j9k0-l1m2-n3o4p5q6r7s8", + "title": "Grilled Salmon", + "description": "A healthy and flavorful seafood dish.", + "ingredients": [ + "4 salmon fillets", + "2 tablespoons olive oil", + "1 lemon, juiced", + "2 cloves garlic, minced", + "1 teaspoon dried dill", + "Salt and pepper to taste" + ], + "instructions": [ + "Preheat grill to medium-high heat.", + "In a small bowl, mix olive oil, lemon juice, garlic, and dill.", + "Season salmon fillets with salt and pepper.", + "Brush the marinade over the salmon fillets.", + "Grill salmon for 4-5 minutes per side, or until it flakes easily.", + "Serve with lemon wedges and your choice of sides." + ], + "tags": [ + "seafood", + "healthy", + "grilling", + "quick" + ], + "notes": "" + }, + { + "id": "d5f1g2h3-i4j5-k6l7-m8n9-o0p1q2r3s4t5", + "title": "Vegetable Lasagna", + "description": "A hearty vegetarian pasta dish.", + "ingredients": [ + "1 package lasagna noodles", + "2 zucchini, sliced", + "2 cups spinach", + "1 cup ricotta cheese", + "1 cup mozzarella cheese", + "1 jar marinara sauce", + "1/4 cup grated Parmesan cheese" + ], + "instructions": [ + "Preheat oven to 375°F (190°C).", + "Cook lasagna noodles according to package directions.", + "In a baking dish, layer noodles, zucchini, spinach, ricotta, and marinara sauce.", + "Repeat layers, ending with sauce and mozzarella on top.", + "Sprinkle with Parmesan cheese.", + "Bake for 25-30 minutes until cheese is melted and bubbly.", + "Let cool for 10 minutes before serving." + ], + "tags": [ + "vegetarian", + "Italian", + "pasta", + "casserole" + ], + "notes": "" + }, + { + "id": "e6g2h3i4-j5k6-l7m8-n9o0-p1q2r3s4t5u6", + "title": "Beef Tacos", + "description": "A Mexican-inspired favorite.", + "ingredients": [ + "1 pound ground beef", + "1 packet taco seasoning", + "8 taco shells", + "1 cup shredded lettuce", + "1 cup diced tomatoes", + "1 cup shredded cheddar cheese", + "1/2 cup sour cream" + ], + "instructions": [ + "Brown ground beef in a skillet over medium heat.", + "Drain excess fat and add taco seasoning with water as directed on packet.", + "Simmer for 5 minutes, stirring occasionally.", + "Warm taco shells according to package directions.", + "Fill shells with beef mixture, lettuce, tomatoes, cheese, and sour cream.", + "Serve immediately." + ], + "tags": [ + "Mexican", + "beef", + "quick", + "family-friendly" + ], + "notes": "" + }, + { + "id": "f7h3i4j5-k6l7-m8n9-o0p1-q2r3s4t5u6v7", + "title": "Caesar Salad", + "description": "A classic salad with a creamy dressing.", + "ingredients": [ + "1 head romaine lettuce", + "1/2 cup Caesar dressing", + "1/4 cup grated Parmesan cheese", + "1 cup croutons", + "1 lemon, juiced" + ], + "instructions": [ + "Wash and chop romaine lettuce into bite-sized pieces.", + "In a large bowl, toss lettuce with Caesar dressing.", + "Add Parmesan cheese and croutons, toss gently.", + "Squeeze fresh lemon juice over the salad.", + "Serve immediately." + ], + "tags": [ + "salad", + "vegetarian", + "quick", + "side dish" + ], + "notes": "" + }, + { + "id": "g8i4j5k6-l7m8-n9o0-p1q2-r3s4t5u6v7w8", + "title": "Chocolate Chip Cookies", + "description": "A beloved sweet treat.", + "ingredients": [ + "2 1/4 cups all-purpose flour", + "1 teaspoon baking soda", + "1 cup unsalted butter, softened", + "3/4 cup granulated sugar", + "3/4 cup brown sugar", + "2 large eggs", + "2 cups semisweet chocolate chips" + ], + "instructions": [ + "Preheat oven to 375°F (190°C).", + "In a bowl, mix flour and baking soda.", + "In another bowl, cream butter and sugars until light and fluffy.", + "Beat in eggs one at a time.", + "Gradually stir in flour mixture, then fold in chocolate chips.", + "Drop spoonfuls of dough onto ungreased baking sheets.", + "Bake for 9-11 minutes or until golden brown.", + "Cool on wire racks." + ], + "tags": [ + "dessert", + "baking", + "cookies", + "chocolate" + ], + "notes": "" + }, + { + "id": "h9j5k6l7-m8n9-o0p1-q2r3-s4t5u6v7w8x9", + "title": "Beef Stroganoff", + "description": "A creamy Russian-inspired dish.", + "ingredients": [ + "1 pound beef sirloin, sliced", + "1 onion, sliced", + "8 ounces mushrooms, sliced", + "1 cup beef broth", + "1 cup sour cream", + "2 tablespoons flour", + "1 package egg noodles" + ], + "instructions": [ + "Cook egg noodles according to package directions.", + "In a large skillet, brown beef over medium-high heat.", + "Add onions and mushrooms, cook until softened.", + "Sprinkle flour over the mixture and stir.", + "Pour in beef broth, simmer until thickened.", + "Stir in sour cream until heated through.", + "Serve over cooked egg noodles." + ], + "tags": [ + "Russian", + "beef", + "pasta", + "comfort food" + ], + "notes": "" + }, + { + "id": "i0k6l7m8-n9o0-p1q2-r3s4-t5u6v7w8x9y0", + "title": "Caprese Salad", + "description": "A simple Italian salad.", + "ingredients": [ + "3 large tomatoes, sliced", + "1 pound fresh mozzarella, sliced", + "1/4 cup fresh basil leaves", + "2 tablespoons olive oil", + "2 tablespoons balsamic vinegar", + "Salt and pepper to taste" + ], + "instructions": [ + "Arrange alternating slices of tomato and mozzarella on a serving platter.", + "Tuck basil leaves between the slices.", + "Drizzle with olive oil and balsamic vinegar.", + "Season with salt and pepper to taste.", + "Serve immediately at room temperature." + ], + "tags": [ + "Italian", + "salad", + "vegetarian", + "no-cook" + ], + "notes": "" + } +] diff --git a/example/ios/.gitignore b/example/ios/.gitignore new file mode 100644 index 0000000..7a7f987 --- /dev/null +++ b/example/ios/.gitignore @@ -0,0 +1,34 @@ +**/dgph +*.mode1v3 +*.mode2v3 +*.moved-aside +*.pbxuser +*.perspectivev3 +**/*sync/ +.sconsign.dblite +.tags* +**/.vagrant/ +**/DerivedData/ +Icon? +**/Pods/ +**/.symlinks/ +profile +xcuserdata +**/.generated/ +Flutter/App.framework +Flutter/Flutter.framework +Flutter/Flutter.podspec +Flutter/Generated.xcconfig +Flutter/ephemeral/ +Flutter/app.flx +Flutter/app.zip +Flutter/flutter_assets/ +Flutter/flutter_export_environment.sh +ServiceDefinitions.json +Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!default.mode1v3 +!default.mode2v3 +!default.pbxuser +!default.perspectivev3 diff --git a/example/ios/Flutter/AppFrameworkInfo.plist b/example/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 0000000..f187d29 --- /dev/null +++ b/example/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,26 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + MinimumOSVersion + 13.0 + + diff --git a/example/ios/Flutter/Debug.xcconfig b/example/ios/Flutter/Debug.xcconfig new file mode 100644 index 0000000..ec97fc6 --- /dev/null +++ b/example/ios/Flutter/Debug.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" +#include "Generated.xcconfig" diff --git a/example/ios/Flutter/Release.xcconfig b/example/ios/Flutter/Release.xcconfig new file mode 100644 index 0000000..c4855bf --- /dev/null +++ b/example/ios/Flutter/Release.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" +#include "Generated.xcconfig" diff --git a/example/ios/Podfile b/example/ios/Podfile new file mode 100644 index 0000000..3e44f9c --- /dev/null +++ b/example/ios/Podfile @@ -0,0 +1,44 @@ +# Uncomment this line to define a global platform for your project +platform :ios, '13.0' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_ios_podfile_setup + +target 'Runner' do + use_frameworks! + use_modular_headers! + + flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_ios_build_settings(target) + end +end diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock new file mode 100644 index 0000000..543aac8 --- /dev/null +++ b/example/ios/Podfile.lock @@ -0,0 +1,174 @@ +PODS: + - AppCheckCore (11.2.0): + - GoogleUtilities/Environment (~> 8.0) + - GoogleUtilities/UserDefaults (~> 8.0) + - PromisesObjC (~> 2.4) + - camera_avfoundation (0.0.1): + - Flutter + - file_selector_ios (0.0.1): + - Flutter + - Firebase/Auth (11.4.0): + - Firebase/CoreOnly + - FirebaseAuth (~> 11.4.0) + - Firebase/CoreOnly (11.4.0): + - FirebaseCore (= 11.4.0) + - firebase_app_check (0.3.1-6): + - Firebase/CoreOnly (~> 11.4.0) + - firebase_core + - FirebaseAppCheck (~> 11.4.0) + - Flutter + - firebase_auth (5.3.3): + - Firebase/Auth (= 11.4.0) + - firebase_core + - Flutter + - firebase_core (3.8.0): + - Firebase/CoreOnly (= 11.4.0) + - Flutter + - FirebaseAppCheck (11.4.0): + - AppCheckCore (~> 11.0) + - FirebaseAppCheckInterop (~> 11.0) + - FirebaseCore (~> 11.0) + - GoogleUtilities/Environment (~> 8.0) + - GoogleUtilities/UserDefaults (~> 8.0) + - FirebaseAppCheckInterop (11.5.0) + - FirebaseAuth (11.4.0): + - FirebaseAppCheckInterop (~> 11.0) + - FirebaseAuthInterop (~> 11.0) + - FirebaseCore (~> 11.4) + - FirebaseCoreExtension (~> 11.4) + - GoogleUtilities/AppDelegateSwizzler (~> 8.0) + - GoogleUtilities/Environment (~> 8.0) + - GTMSessionFetcher/Core (< 5.0, >= 3.4) + - RecaptchaInterop (~> 100.0) + - FirebaseAuthInterop (11.5.0) + - FirebaseCore (11.4.0): + - FirebaseCoreInternal (~> 11.0) + - GoogleUtilities/Environment (~> 8.0) + - GoogleUtilities/Logger (~> 8.0) + - FirebaseCoreExtension (11.4.1): + - FirebaseCore (~> 11.0) + - FirebaseCoreInternal (11.5.0): + - "GoogleUtilities/NSData+zlib (~> 8.0)" + - Flutter (1.0.0) + - GoogleUtilities/AppDelegateSwizzler (8.0.2): + - GoogleUtilities/Environment + - GoogleUtilities/Logger + - GoogleUtilities/Network + - GoogleUtilities/Privacy + - GoogleUtilities/Environment (8.0.2): + - GoogleUtilities/Privacy + - GoogleUtilities/Logger (8.0.2): + - GoogleUtilities/Environment + - GoogleUtilities/Privacy + - GoogleUtilities/Network (8.0.2): + - GoogleUtilities/Logger + - "GoogleUtilities/NSData+zlib" + - GoogleUtilities/Privacy + - GoogleUtilities/Reachability + - "GoogleUtilities/NSData+zlib (8.0.2)": + - GoogleUtilities/Privacy + - GoogleUtilities/Privacy (8.0.2) + - GoogleUtilities/Reachability (8.0.2): + - GoogleUtilities/Logger + - GoogleUtilities/Privacy + - GoogleUtilities/UserDefaults (8.0.2): + - GoogleUtilities/Logger + - GoogleUtilities/Privacy + - GTMSessionFetcher/Core (4.1.0) + - image_picker_ios (0.0.1): + - Flutter + - path_provider_foundation (0.0.1): + - Flutter + - FlutterMacOS + - PromisesObjC (2.4.0) + - RecaptchaInterop (100.0.0) + - record_darwin (1.0.0): + - Flutter + - shared_preferences_foundation (0.0.1): + - Flutter + - FlutterMacOS + - url_launcher_ios (0.0.1): + - Flutter + +DEPENDENCIES: + - camera_avfoundation (from `.symlinks/plugins/camera_avfoundation/ios`) + - file_selector_ios (from `.symlinks/plugins/file_selector_ios/ios`) + - firebase_app_check (from `.symlinks/plugins/firebase_app_check/ios`) + - firebase_auth (from `.symlinks/plugins/firebase_auth/ios`) + - firebase_core (from `.symlinks/plugins/firebase_core/ios`) + - Flutter (from `Flutter`) + - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`) + - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) + - record_darwin (from `.symlinks/plugins/record_darwin/ios`) + - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) + - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) + +SPEC REPOS: + trunk: + - AppCheckCore + - Firebase + - FirebaseAppCheck + - FirebaseAppCheckInterop + - FirebaseAuth + - FirebaseAuthInterop + - FirebaseCore + - FirebaseCoreExtension + - FirebaseCoreInternal + - GoogleUtilities + - GTMSessionFetcher + - PromisesObjC + - RecaptchaInterop + +EXTERNAL SOURCES: + camera_avfoundation: + :path: ".symlinks/plugins/camera_avfoundation/ios" + file_selector_ios: + :path: ".symlinks/plugins/file_selector_ios/ios" + firebase_app_check: + :path: ".symlinks/plugins/firebase_app_check/ios" + firebase_auth: + :path: ".symlinks/plugins/firebase_auth/ios" + firebase_core: + :path: ".symlinks/plugins/firebase_core/ios" + Flutter: + :path: Flutter + image_picker_ios: + :path: ".symlinks/plugins/image_picker_ios/ios" + path_provider_foundation: + :path: ".symlinks/plugins/path_provider_foundation/darwin" + record_darwin: + :path: ".symlinks/plugins/record_darwin/ios" + shared_preferences_foundation: + :path: ".symlinks/plugins/shared_preferences_foundation/darwin" + url_launcher_ios: + :path: ".symlinks/plugins/url_launcher_ios/ios" + +SPEC CHECKSUMS: + AppCheckCore: cc8fd0a3a230ddd401f326489c99990b013f0c4f + camera_avfoundation: dd002b0330f4981e1bbcb46ae9b62829237459a4 + file_selector_ios: f0670c1064a8c8450e38145d8043160105d0b97c + Firebase: cf1b19f21410b029b6786a54e9764a0cacad3c99 + firebase_app_check: f14f9436aeb921d8dd27eea75a524c19198f088f + firebase_auth: 42718683069d35d73af7a986b55b194589039e5e + firebase_core: 9efc3ecf689cdbc90f13f4dc58108c83ea46b266 + FirebaseAppCheck: 933cbda29279ed316b82360bca77602ac1af1ff2 + FirebaseAppCheckInterop: d265d9f4484e7ec1c591086408840fdd383d1213 + FirebaseAuth: c359af98bd703cbf4293eec107a40de08ede6ce6 + FirebaseAuthInterop: 1219bee9b23e6ebe84c256a0d95adab53d11c331 + FirebaseCore: e0510f1523bc0eb21653cac00792e1e2bd6f1771 + FirebaseCoreExtension: f1bc67a4702931a7caa097d8e4ac0a1b0d16720e + FirebaseCoreInternal: f47dd28ae7782e6a4738aad3106071a8fe0af604 + Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 + GoogleUtilities: 26a3abef001b6533cf678d3eb38fd3f614b7872d + GTMSessionFetcher: 923b710231ad3d6f3f0495ac1ced35421e07d9a6 + image_picker_ios: c560581cceedb403a6ff17f2f816d7fea1421fc1 + path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46 + PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 + RecaptchaInterop: 7d1a4a01a6b2cb1610a47ef3f85f0c411434cb21 + record_darwin: 3b1a8e7d5c0cbf45ad6165b4d83a6ca643d929c3 + shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78 + url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe + +PODFILE CHECKSUM: a57f30d18f102dd3ce366b1d62a55ecbef2158e5 + +COCOAPODS: 1.16.2 diff --git a/example/ios/Runner.xcodeproj/project.pbxproj b/example/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..bae8d4c --- /dev/null +++ b/example/ios/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,746 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXBuildFile section */ + 0E4E53B93D40EBC025EAFB5D /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AFFE989D73E32AEEE4647736 /* Pods_Runner.framework */; }; + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; + C707359D9395B8ADE554AE09 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 839B29CDE5F56A2710FDA9B3 /* Pods_RunnerTests.framework */; }; + D565CCEEA83645FC368483B6 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 0525572EEDBE97E74D7DDF35 /* GoogleService-Info.plist */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 97C146ED1CF9000F007C117D; + remoteInfo = Runner; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 9705A1C41CF9048500538489 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 0525572EEDBE97E74D7DDF35 /* GoogleService-Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; name = "GoogleService-Info.plist"; path = "Runner/GoogleService-Info.plist"; sourceTree = ""; }; + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 221C71E96F06A73F7FE3E47D /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; + 2B8E9F70FCC1D273DFF3570B /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; + 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 4F8A92A47610FF2F23D4DE5A /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; + 640DD9EB0BBA78A6C23488AF /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 839B29CDE5F56A2710FDA9B3 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 8BF537FE9573349E96B3AFE9 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; + 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; + 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + AFFE989D73E32AEEE4647736 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + D7115767CBE6A71CE38E828D /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 53C62B63FFDC864785BE7F7C /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + C707359D9395B8ADE554AE09 /* Pods_RunnerTests.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EB1CF9000F007C117D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 0E4E53B93D40EBC025EAFB5D /* Pods_Runner.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C8082294A63A400263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C807B294A618700263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 9740EEB11CF90186004384FC /* Flutter */ = { + isa = PBXGroup; + children = ( + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 9740EEB31CF90195004384FC /* Generated.xcconfig */, + ); + name = Flutter; + sourceTree = ""; + }; + 97C146E51CF9000F007C117D = { + isa = PBXGroup; + children = ( + 9740EEB11CF90186004384FC /* Flutter */, + 97C146F01CF9000F007C117D /* Runner */, + 97C146EF1CF9000F007C117D /* Products */, + 331C8082294A63A400263BE5 /* RunnerTests */, + 99FEB944BC7811B3E28CD8B7 /* Pods */, + 0525572EEDBE97E74D7DDF35 /* GoogleService-Info.plist */, + B34F4B293B748E6A7B70CE0B /* Frameworks */, + ); + sourceTree = ""; + }; + 97C146EF1CF9000F007C117D /* Products */ = { + isa = PBXGroup; + children = ( + 97C146EE1CF9000F007C117D /* Runner.app */, + 331C8081294A63A400263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 97C146F01CF9000F007C117D /* Runner */ = { + isa = PBXGroup; + children = ( + 97C146FA1CF9000F007C117D /* Main.storyboard */, + 97C146FD1CF9000F007C117D /* Assets.xcassets */, + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, + 97C147021CF9000F007C117D /* Info.plist */, + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + ); + path = Runner; + sourceTree = ""; + }; + 99FEB944BC7811B3E28CD8B7 /* Pods */ = { + isa = PBXGroup; + children = ( + 640DD9EB0BBA78A6C23488AF /* Pods-Runner.debug.xcconfig */, + D7115767CBE6A71CE38E828D /* Pods-Runner.release.xcconfig */, + 8BF537FE9573349E96B3AFE9 /* Pods-Runner.profile.xcconfig */, + 221C71E96F06A73F7FE3E47D /* Pods-RunnerTests.debug.xcconfig */, + 2B8E9F70FCC1D273DFF3570B /* Pods-RunnerTests.release.xcconfig */, + 4F8A92A47610FF2F23D4DE5A /* Pods-RunnerTests.profile.xcconfig */, + ); + path = Pods; + sourceTree = ""; + }; + B34F4B293B748E6A7B70CE0B /* Frameworks */ = { + isa = PBXGroup; + children = ( + AFFE989D73E32AEEE4647736 /* Pods_Runner.framework */, + 839B29CDE5F56A2710FDA9B3 /* Pods_RunnerTests.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C8080294A63A400263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + D5794DEF5D9F8D1433F1DFC6 /* [CP] Check Pods Manifest.lock */, + 331C807D294A63A400263BE5 /* Sources */, + 331C807F294A63A400263BE5 /* Resources */, + 53C62B63FFDC864785BE7F7C /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + 331C8086294A63A400263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 97C146ED1CF9000F007C117D /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 96AF24085E585428D083FAAA /* [CP] Check Pods Manifest.lock */, + 9740EEB61CF901F6004384FC /* Run Script */, + 97C146EA1CF9000F007C117D /* Sources */, + 97C146EB1CF9000F007C117D /* Frameworks */, + 97C146EC1CF9000F007C117D /* Resources */, + 9705A1C41CF9048500538489 /* Embed Frameworks */, + 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + E59DCCA4BFF189341F0408F8 /* [CP] Embed Pods Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Runner; + productName = Runner; + productReference = 97C146EE1CF9000F007C117D /* Runner.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 97C146E61CF9000F007C117D /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C8080294A63A400263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 97C146ED1CF9000F007C117D; + }; + 97C146ED1CF9000F007C117D = { + CreatedOnToolsVersion = 7.3.1; + LastSwiftMigration = 1100; + }; + }; + }; + buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 97C146E51CF9000F007C117D; + productRefGroup = 97C146EF1CF9000F007C117D /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 97C146ED1CF9000F007C117D /* Runner */, + 331C8080294A63A400263BE5 /* RunnerTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C807F294A63A400263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EC1CF9000F007C117D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, + D565CCEEA83645FC368483B6 /* GoogleService-Info.plist in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", + ); + name = "Thin Binary"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; + }; + 96AF24085E585428D083FAAA /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 9740EEB61CF901F6004384FC /* Run Script */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Run Script"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; + }; + D5794DEF5D9F8D1433F1DFC6 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + E59DCCA4BFF189341F0408F8 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C807D294A63A400263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EA1CF9000F007C117D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C8086294A63A400263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 97C146ED1CF9000F007C117D /* Runner */; + targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 97C146FA1CF9000F007C117D /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C146FB1CF9000F007C117D /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C147001CF9000F007C117D /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 249021D3217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Profile; + }; + 249021D4217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = 7Y2C479G3R; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.example.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Profile; + }; + 331C8088294A63A400263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 221C71E96F06A73F7FE3E47D /* Pods-RunnerTests.debug.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.example.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Debug; + }; + 331C8089294A63A400263BE5 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 2B8E9F70FCC1D273DFF3570B /* Pods-RunnerTests.release.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.example.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Release; + }; + 331C808A294A63A400263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 4F8A92A47610FF2F23D4DE5A /* Pods-RunnerTests.profile.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.example.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Profile; + }; + 97C147031CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 97C147041CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 97C147061CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = 7Y2C479G3R; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.example.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + 97C147071CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = 7Y2C479G3R; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.example.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C8088294A63A400263BE5 /* Debug */, + 331C8089294A63A400263BE5 /* Release */, + 331C808A294A63A400263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147031CF9000F007C117D /* Debug */, + 97C147041CF9000F007C117D /* Release */, + 249021D3217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147061CF9000F007C117D /* Debug */, + 97C147071CF9000F007C117D /* Release */, + 249021D4217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 97C146E61CF9000F007C117D /* Project object */; +} diff --git a/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..8e3ca5d --- /dev/null +++ b/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/example/ios/Runner.xcworkspace/contents.xcworkspacedata b/example/ios/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..21a3cc1 --- /dev/null +++ b/example/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/example/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/example/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/example/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/example/ios/Runner/AppDelegate.swift b/example/ios/Runner/AppDelegate.swift new file mode 100644 index 0000000..6266644 --- /dev/null +++ b/example/ios/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import Flutter +import UIKit + +@main +@objc class AppDelegate: FlutterAppDelegate { + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + GeneratedPluginRegistrant.register(with: self) + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } +} diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..d36b1fa --- /dev/null +++ b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,122 @@ +{ + "images" : [ + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@3x.png", + "scale" : "3x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@3x.png", + "scale" : "3x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@3x.png", + "scale" : "3x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@2x.png", + "scale" : "2x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@3x.png", + "scale" : "3x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@1x.png", + "scale" : "1x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@1x.png", + "scale" : "1x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@1x.png", + "scale" : "1x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@2x.png", + "scale" : "2x" + }, + { + "size" : "83.5x83.5", + "idiom" : "ipad", + "filename" : "Icon-App-83.5x83.5@2x.png", + "scale" : "2x" + }, + { + "size" : "1024x1024", + "idiom" : "ios-marketing", + "filename" : "Icon-App-1024x1024@1x.png", + "scale" : "1x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 0000000..dc9ada4 Binary files /dev/null and b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png new file mode 100644 index 0000000..7353c41 Binary files /dev/null and b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 0000000..797d452 Binary files /dev/null and b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 0000000..6ed2d93 Binary files /dev/null and b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png new file mode 100644 index 0000000..4cd7b00 Binary files /dev/null and b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 0000000..fe73094 Binary files /dev/null and b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png new file mode 100644 index 0000000..321773c Binary files /dev/null and b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png new file mode 100644 index 0000000..797d452 Binary files /dev/null and b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 0000000..502f463 Binary files /dev/null and b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png new file mode 100644 index 0000000..0ec3034 Binary files /dev/null and b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 0000000..0ec3034 Binary files /dev/null and b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 0000000..e9f5fea Binary files /dev/null and b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png new file mode 100644 index 0000000..84ac32a Binary files /dev/null and b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png new file mode 100644 index 0000000..8953cba Binary files /dev/null and b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png new file mode 100644 index 0000000..0467bf1 Binary files /dev/null and b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json new file mode 100644 index 0000000..0bedcf2 --- /dev/null +++ b/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "LaunchImage.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ diff --git a/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ diff --git a/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ diff --git a/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md new file mode 100644 index 0000000..89c2725 --- /dev/null +++ b/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md @@ -0,0 +1,5 @@ +# Launch Screen Assets + +You can customize the launch screen with your own desired assets by replacing the image files in this directory. + +You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. \ No newline at end of file diff --git a/example/ios/Runner/Base.lproj/LaunchScreen.storyboard b/example/ios/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..f2e259c --- /dev/null +++ b/example/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/example/ios/Runner/Base.lproj/Main.storyboard b/example/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 0000000..f3c2851 --- /dev/null +++ b/example/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/example/ios/Runner/Info.plist b/example/ios/Runner/Info.plist new file mode 100644 index 0000000..9cdbdf5 --- /dev/null +++ b/example/ios/Runner/Info.plist @@ -0,0 +1,55 @@ + + + + + CADisableMinimumFrameDurationOnPhone + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Example + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + example + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + NSCameraUsageDescription + $(PRODUCT_NAME) would like to access your camera. + NSMicrophoneUsageDescription + $(PRODUCT_NAME) would like to access your microphone. + NSPhotoLibraryUsageDescription + $(PRODUCT_NAME) would like access to your photos. + UIApplicationSupportsIndirectInputEvents + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/example/ios/Runner/Runner-Bridging-Header.h b/example/ios/Runner/Runner-Bridging-Header.h new file mode 100644 index 0000000..308a2a5 --- /dev/null +++ b/example/ios/Runner/Runner-Bridging-Header.h @@ -0,0 +1 @@ +#import "GeneratedPluginRegistrant.h" diff --git a/example/ios/RunnerTests/RunnerTests.swift b/example/ios/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..86a7c3b --- /dev/null +++ b/example/ios/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Flutter +import UIKit +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/example/lib/cupertino/cupertino.dart b/example/lib/cupertino/cupertino.dart new file mode 100644 index 0000000..95a13db --- /dev/null +++ b/example/lib/cupertino/cupertino.dart @@ -0,0 +1,42 @@ +// Copyright 2024 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/cupertino.dart'; +import 'package:flutter_ai_toolkit/flutter_ai_toolkit.dart'; +import 'package:google_generative_ai/google_generative_ai.dart'; + +import '../gemini_api_key.dart'; + +void main() => runApp(const App()); + +class App extends StatelessWidget { + static const title = 'Example: Cupertino'; + + const App({super.key}); + + @override + Widget build(BuildContext context) => const CupertinoApp( + title: title, + home: ChatPage(), + ); +} + +class ChatPage extends StatelessWidget { + const ChatPage({super.key}); + + @override + Widget build(BuildContext context) => CupertinoPageScaffold( + navigationBar: CupertinoNavigationBar( + middle: Text(App.title), + ), + child: LlmChatView( + provider: GeminiProvider( + model: GenerativeModel( + model: 'gemini-1.5-flash', + apiKey: geminiApiKey, + ), + ), + ), + ); +} diff --git a/example/lib/custom_styles/custom_styles.dart b/example/lib/custom_styles/custom_styles.dart new file mode 100644 index 0000000..46e5494 --- /dev/null +++ b/example/lib/custom_styles/custom_styles.dart @@ -0,0 +1,261 @@ +// Copyright 2024 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; +import 'package:flutter_ai_toolkit/flutter_ai_toolkit.dart'; +import 'package:flutter_markdown/flutter_markdown.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:google_generative_ai/google_generative_ai.dart'; + +import '../gemini_api_key.dart'; + +void main() => runApp(const App()); + +class App extends StatelessWidget { + static const title = 'Example: Custom Styles'; + const App({super.key}); + + @override + Widget build(BuildContext context) => MaterialApp( + title: title, + theme: ThemeData.from( + colorScheme: ColorScheme.fromSeed(seedColor: Colors.orange), + ), + debugShowCheckedModeBanner: false, + home: ChatPage(), + ); +} + +class ChatPage extends StatefulWidget { + const ChatPage({super.key}); + + @override + State createState() => _ChatPageState(); +} + +class _ChatPageState extends State + with SingleTickerProviderStateMixin { + late final _animationController = AnimationController( + duration: const Duration(seconds: 1), + vsync: this, + lowerBound: 0.25, + upperBound: 1.0, + ); + + final _provider = GeminiProvider( + model: GenerativeModel( + model: 'gemini-1.5-flash', + apiKey: geminiApiKey, + ), + ); + + @override + void initState() { + super.initState(); + _clearHistory(); + } + + void _clearHistory() { + _provider.history = []; + _animationController.value = 1.0; + _animationController.reverse(); + } + + @override + void dispose() { + _animationController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text(App.title), + actions: [ + IconButton( + onPressed: _clearHistory, + tooltip: 'Clear History', + icon: const Icon(Icons.history), + ), + ], + ), + body: AnimatedBuilder( + animation: _animationController, + builder: (context, child) => Stack( + children: [ + SizedBox( + height: double.infinity, + width: double.infinity, + child: Image.asset( + 'assets/halloween-bg.png', + fit: BoxFit.cover, + opacity: _animationController, + ), + ), + LlmChatView( + provider: _provider, + welcomeMessage: 'Welcome to the Custom Styles Example! Use the ' + 'butons on the action bar at the top right of the app to ' + 'explore light and dark styles in combination with normal ' + 'and Halloween-themed styles. Enjoy!', + suggestions: [ + 'I\'m a Star Wars fan. What should I wear for Halloween?', + 'I\'m allergic to peanuts. What candy should I avoid at ' + 'Halloween?', + 'What\'s the difference between a pumpkin and a squash?', + ], + style: style, + ), + ], + ), + ), + ); + } + + LlmChatViewStyle get style { + final TextStyle halloweenTextStyle = GoogleFonts.hennyPenny( + color: Colors.white, + fontSize: 24, + ); + + final halloweenActionButtonStyle = ActionButtonStyle( + tooltipTextStyle: halloweenTextStyle, + iconColor: Colors.black, + iconDecoration: BoxDecoration( + color: Colors.orange, + borderRadius: BorderRadius.circular(8), + ), + ); + + final halloweenMenuButtonStyle = ActionButtonStyle( + tooltipTextStyle: halloweenTextStyle, + iconColor: Colors.orange, + iconDecoration: BoxDecoration( + color: Colors.black, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.orange), + ), + ); + + return LlmChatViewStyle( + backgroundColor: Colors.transparent, + progressIndicatorColor: Colors.purple, + suggestionStyle: SuggestionStyle( + textStyle: halloweenTextStyle.copyWith(color: Colors.black), + decoration: BoxDecoration( + color: Colors.yellow, + border: Border.all(color: Colors.orange), + ), + ), + chatInputStyle: ChatInputStyle( + backgroundColor: _animationController.isAnimating + ? Colors.transparent + : Colors.black, + decoration: BoxDecoration( + color: Colors.yellow, + border: Border.all(color: Colors.orange), + ), + textStyle: halloweenTextStyle.copyWith(color: Colors.black), + hintText: 'good evening...', + hintStyle: + halloweenTextStyle.copyWith(color: Colors.orange.withOpacity(.5)), + ), + userMessageStyle: UserMessageStyle( + textStyle: halloweenTextStyle.copyWith(color: Colors.black), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + Colors.white, + Colors.grey.shade300, + Colors.grey.shade400, + ], + ), + borderRadius: BorderRadius.circular(20), + boxShadow: [ + BoxShadow( + color: Colors.grey.withOpacity(0.5), + blurRadius: 10, + spreadRadius: 2, + ), + ], + ), + ), + llmMessageStyle: LlmMessageStyle( + icon: Icons.sentiment_very_satisfied, + iconColor: Colors.black, + iconDecoration: BoxDecoration( + color: Colors.orange, + borderRadius: BorderRadius.only( + topLeft: Radius.circular(8), + bottomLeft: Radius.circular(8), + topRight: Radius.zero, + bottomRight: Radius.circular(8), + ), + border: Border.all(color: Colors.black), + ), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + Colors.deepOrange.shade900, + Colors.orange.shade800, + Colors.purple.shade900, + ], + ), + borderRadius: BorderRadius.only( + topLeft: Radius.zero, + bottomLeft: Radius.circular(20), + topRight: Radius.circular(20), + bottomRight: Radius.circular(20), + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.3), + blurRadius: 8, + offset: Offset(2, 2), + ), + ], + ), + markdownStyle: MarkdownStyleSheet( + p: halloweenTextStyle, + listBullet: halloweenTextStyle, + ), + ), + recordButtonStyle: halloweenActionButtonStyle, + stopButtonStyle: halloweenActionButtonStyle, + submitButtonStyle: halloweenActionButtonStyle, + addButtonStyle: halloweenActionButtonStyle, + attachFileButtonStyle: halloweenMenuButtonStyle, + cameraButtonStyle: halloweenMenuButtonStyle, + closeButtonStyle: halloweenActionButtonStyle, + cancelButtonStyle: halloweenActionButtonStyle, + closeMenuButtonStyle: halloweenActionButtonStyle, + copyButtonStyle: halloweenMenuButtonStyle, + editButtonStyle: halloweenMenuButtonStyle, + galleryButtonStyle: halloweenMenuButtonStyle, + actionButtonBarDecoration: BoxDecoration( + color: Colors.orange, + borderRadius: BorderRadius.circular(8), + ), + fileAttachmentStyle: FileAttachmentStyle( + decoration: BoxDecoration( + color: Colors.black, + ), + iconDecoration: BoxDecoration( + color: Colors.orange, + borderRadius: BorderRadius.circular(8), + ), + filenameStyle: halloweenTextStyle, + filetypeStyle: halloweenTextStyle.copyWith( + color: Colors.green, + fontSize: 18, + ), + ), + ); + } +} diff --git a/example/lib/dark_mode/dark_mode.dart b/example/lib/dark_mode/dark_mode.dart new file mode 100644 index 0000000..2cab479 --- /dev/null +++ b/example/lib/dark_mode/dark_mode.dart @@ -0,0 +1,80 @@ +// Copyright 2024 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; +import 'package:flutter_ai_toolkit/flutter_ai_toolkit.dart'; +import 'package:google_generative_ai/google_generative_ai.dart'; + +import '../gemini_api_key.dart'; +import '../dark_style.dart'; + +void main() => runApp(const App()); + +class App extends StatelessWidget { + static const title = 'Example: Dark Mode'; + static final themeMode = ValueNotifier(ThemeMode.dark); + + const App({super.key}); + + @override + Widget build(BuildContext context) => ValueListenableBuilder( + valueListenable: themeMode, + builder: ( + BuildContext context, + ThemeMode mode, + Widget? child, + ) => + MaterialApp( + title: title, + theme: ThemeData.light(), + darkTheme: ThemeData.dark(), + themeMode: mode, + home: ChatPage(), + debugShowCheckedModeBanner: false, + ), + ); +} + +class ChatPage extends StatefulWidget { + const ChatPage({super.key}); + + @override + State createState() => _ChatPageState(); +} + +class _ChatPageState extends State { + final _provider = GeminiProvider( + model: GenerativeModel( + model: 'gemini-1.5-flash', + apiKey: geminiApiKey, + ), + ); + + final _lightStyle = LlmChatViewStyle.defaultStyle(); + final _darkStyle = darkChatViewStyle(); + + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar( + title: const Text(App.title), + actions: [ + IconButton( + onPressed: () => App.themeMode.value = + App.themeMode.value == ThemeMode.light + ? ThemeMode.dark + : ThemeMode.light, + tooltip: App.themeMode.value == ThemeMode.light + ? 'Dark Mode' + : 'Light Mode', + icon: const Icon(Icons.brightness_4_outlined), + ), + ], + ), + body: LlmChatView( + provider: _provider, + style: + App.themeMode.value == ThemeMode.dark ? _darkStyle : _lightStyle, + ), + ); +} diff --git a/example/lib/dark_style.dart b/example/lib/dark_style.dart new file mode 100644 index 0000000..2898bf6 --- /dev/null +++ b/example/lib/dark_style.dart @@ -0,0 +1,179 @@ +// Copyright 2024 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; +import 'package:flutter_ai_toolkit/flutter_ai_toolkit.dart'; +import 'package:flutter_markdown/flutter_markdown.dart'; +import 'package:google_fonts/google_fonts.dart'; + +LlmChatViewStyle darkChatViewStyle() { + final style = LlmChatViewStyle.defaultStyle(); + return LlmChatViewStyle( + backgroundColor: _invertColor(style.backgroundColor), + progressIndicatorColor: _invertColor(style.progressIndicatorColor), + userMessageStyle: _darkUserMessageStyle(), + llmMessageStyle: _darkLlmMessageStyle(), + chatInputStyle: _darkChatInputStyle(), + addButtonStyle: _darkActionButtonStyle(ActionButtonType.add), + attachFileButtonStyle: _darkActionButtonStyle(ActionButtonType.attachFile), + cameraButtonStyle: _darkActionButtonStyle(ActionButtonType.camera), + stopButtonStyle: _darkActionButtonStyle(ActionButtonType.stop), + recordButtonStyle: _darkActionButtonStyle(ActionButtonType.record), + submitButtonStyle: _darkActionButtonStyle(ActionButtonType.submit), + closeMenuButtonStyle: _darkActionButtonStyle(ActionButtonType.closeMenu), + actionButtonBarDecoration: + _invertDecoration(style.actionButtonBarDecoration), + fileAttachmentStyle: _darkFileAttachmentStyle(), + suggestionStyle: _darkSuggestionStyle(), + closeButtonStyle: _darkActionButtonStyle(ActionButtonType.close), + cancelButtonStyle: _darkActionButtonStyle(ActionButtonType.cancel), + copyButtonStyle: _darkActionButtonStyle(ActionButtonType.copy), + editButtonStyle: _darkActionButtonStyle(ActionButtonType.edit), + galleryButtonStyle: _darkActionButtonStyle(ActionButtonType.gallery), + ); +} + +UserMessageStyle _darkUserMessageStyle() { + final style = UserMessageStyle.defaultStyle(); + return UserMessageStyle( + textStyle: _invertTextStyle(style.textStyle), + // inversion doesn't look great here + // decoration: invertDecoration(style.decoration), + decoration: (style.decoration! as BoxDecoration).copyWith( + color: _greyBackground, + ), + ); +} + +LlmMessageStyle _darkLlmMessageStyle() { + final style = LlmMessageStyle.defaultStyle(); + return LlmMessageStyle( + icon: style.icon, + iconColor: _invertColor(style.iconColor), + // inversion doesn't look great here + // iconDecoration: invertDecoration(style.iconDecoration), + iconDecoration: BoxDecoration( + color: _greyBackground, + shape: BoxShape.circle, + ), + markdownStyle: _invertMarkdownStyle(style.markdownStyle), + decoration: _invertDecoration(style.decoration), + ); +} + +ChatInputStyle _darkChatInputStyle() { + final style = ChatInputStyle.defaultStyle(); + return ChatInputStyle( + decoration: _invertDecoration(style.decoration), + textStyle: _invertTextStyle(style.textStyle), + // inversion doesn't look great here + // hintStyle: invertTextStyle(style.hintStyle), + hintStyle: GoogleFonts.roboto( + color: _greyBackground, + fontSize: 14, + fontWeight: FontWeight.w400, + ), + hintText: style.hintText, + backgroundColor: _invertColor(style.backgroundColor), + ); +} + +ActionButtonStyle _darkActionButtonStyle(ActionButtonType type) { + final style = ActionButtonStyle.defaultStyle(type); + return ActionButtonStyle( + icon: style.icon, + iconColor: _invertColor(style.iconColor), + iconDecoration: switch (type) { + ActionButtonType.add || + ActionButtonType.record || + ActionButtonType.stop => + BoxDecoration( + color: _greyBackground, + shape: BoxShape.circle, + ), + _ => _invertDecoration(style.iconDecoration), + }, + tooltip: style.tooltip, + tooltipTextStyle: _invertTextStyle(style.tooltipTextStyle), + tooltipDecoration: _invertDecoration(style.tooltipDecoration), + ); +} + +FileAttachmentStyle _darkFileAttachmentStyle() { + final style = FileAttachmentStyle.defaultStyle(); + return FileAttachmentStyle( + // inversion doesn't look great here + // decoration: invertDecoration(style.decoration), + decoration: ShapeDecoration( + color: _greyBackground, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + icon: style.icon, + iconColor: _invertColor(style.iconColor), + iconDecoration: _invertDecoration(style.iconDecoration), + filenameStyle: _invertTextStyle(style.filenameStyle), + // inversion doesn't look great here + // filetypeStyle: invertTextStyle(style.filetypeStyle), + filetypeStyle: style.filetypeStyle!.copyWith(color: Colors.black), + ); +} + +SuggestionStyle _darkSuggestionStyle() { + final style = SuggestionStyle.defaultStyle(); + return SuggestionStyle( + textStyle: _invertTextStyle(style.textStyle), + decoration: BoxDecoration( + color: _greyBackground, + borderRadius: BorderRadius.all(Radius.circular(8)), + ), + ); +} + +const Color _greyBackground = Color(0xFF535353); + +Color _invertColor(Color? color) => Color.fromARGB( + color!.alpha, + 255 - color.red, + 255 - color.green, + 255 - color.blue, + ); + +Decoration _invertDecoration(Decoration? decoration) => switch (decoration!) { + final BoxDecoration d => d.copyWith(color: _invertColor(d.color)), + final ShapeDecoration d => ShapeDecoration( + color: _invertColor(d.color), + shape: d.shape, + shadows: d.shadows, + image: d.image, + gradient: d.gradient, + ), + _ => decoration, + }; + +TextStyle _invertTextStyle(TextStyle? style) => + style!.copyWith(color: _invertColor(style.color)); + +MarkdownStyleSheet? _invertMarkdownStyle(MarkdownStyleSheet? markdownStyle) => + markdownStyle?.copyWith( + a: _invertTextStyle(markdownStyle.a), + blockquote: _invertTextStyle(markdownStyle.blockquote), + checkbox: _invertTextStyle(markdownStyle.checkbox), + code: _invertTextStyle(markdownStyle.code), + del: _invertTextStyle(markdownStyle.del), + em: _invertTextStyle(markdownStyle.em), + strong: _invertTextStyle(markdownStyle.strong), + p: _invertTextStyle(markdownStyle.p), + tableBody: _invertTextStyle(markdownStyle.tableBody), + tableHead: _invertTextStyle(markdownStyle.tableHead), + h1: _invertTextStyle(markdownStyle.h1), + h2: _invertTextStyle(markdownStyle.h2), + h3: _invertTextStyle(markdownStyle.h3), + h4: _invertTextStyle(markdownStyle.h4), + h5: _invertTextStyle(markdownStyle.h5), + h6: _invertTextStyle(markdownStyle.h6), + listBullet: _invertTextStyle(markdownStyle.listBullet), + img: _invertTextStyle(markdownStyle.img), + ); diff --git a/example/lib/demo/api_key_page.dart b/example/lib/demo/api_key_page.dart new file mode 100644 index 0000000..1378718 --- /dev/null +++ b/example/lib/demo/api_key_page.dart @@ -0,0 +1,98 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:gap/gap.dart'; +import 'package:url_launcher/url_launcher.dart'; + +class GeminiApiKeyPage extends StatefulWidget { + const GeminiApiKeyPage({ + required this.title, + required this.onApiKey, + super.key, + }); + + final String title; + final void Function(String apiKey) onApiKey; + + @override + State createState() => _GeminiApiKeyPageState(); +} + +class _GeminiApiKeyPageState extends State { + static final url = Uri.parse('https://aistudio.google.com/app/apikey'); + final _controller = TextEditingController(); + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar(title: Text(widget.title)), + body: Center( + child: ValueListenableBuilder( + valueListenable: _controller, + builder: (context, value, child) => Column( + children: [ + const Text('To run this sample, you need a Gemini API key.\n' + 'Get your Gemini API Key from the following URL:'), + GestureDetector( + onTap: () => launchUrl(url, webOnlyWindowName: '_blank'), + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: Text( + url.toString(), + style: const TextStyle( + color: Colors.blue, + decoration: TextDecoration.underline, + ), + ), + ), + ), + GestureDetector( + onTap: _copyUrl, + child: const MouseRegion( + cursor: SystemMouseCursors.click, + child: Text('(or copy the URL above by tapping HERE)'), + ), + ), + const Gap(16), + const Text('Paste your API key here:'), + SizedBox( + width: 300, + child: TextField( + controller: _controller, + decoration: InputDecoration( + labelText: 'Gemini API Key', + errorText: _isValidApiKey() + ? null + : 'API key must be 39 characters', + ), + onSubmitted: _isValidApiKey() + ? (apiKey) => widget.onApiKey(apiKey) + : null, + ), + ), + const Gap(16), + ElevatedButton( + onPressed: _isValidApiKey() + ? () => widget.onApiKey(_controller.text) + : null, + child: const Text('Submit'), + ), + ], + ), + ), + ), + ); + + bool _isValidApiKey() => _controller.text.length == 39; + + void _copyUrl() { + Clipboard.setData(ClipboardData(text: url.toString())); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Copied URL to clipboard')), + ); + } +} diff --git a/example/lib/demo/demo.dart b/example/lib/demo/demo.dart new file mode 100644 index 0000000..7f2a99b --- /dev/null +++ b/example/lib/demo/demo.dart @@ -0,0 +1,349 @@ +// Copyright 2024 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; +import 'package:flutter_ai_toolkit/flutter_ai_toolkit.dart'; +import 'package:flutter_markdown/flutter_markdown.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:google_generative_ai/google_generative_ai.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../dark_style.dart'; +import 'api_key_page.dart'; + +late final SharedPreferences prefs; + +void main() async { + WidgetsFlutterBinding.ensureInitialized(); + final prefs = await SharedPreferences.getInstance(); + runApp(App(prefs: prefs)); +} + +class App extends StatefulWidget { + static const title = 'Demo: Flutter AI Toolkit'; + static final themeMode = ValueNotifier(ThemeMode.light); + + const App({super.key, required this.prefs}); + final SharedPreferences prefs; + + @override + State createState() => _AppState(); +} + +class _AppState extends State { + String? _geminiApiKey; + + @override + void initState() { + super.initState(); + _geminiApiKey = widget.prefs.getString('gemini_api_key'); + } + + @override + Widget build(BuildContext context) => ValueListenableBuilder( + valueListenable: App.themeMode, + builder: (context, mode, child) => MaterialApp( + title: App.title, + theme: ThemeData.light(), + darkTheme: ThemeData.dark(), + themeMode: mode, + home: _geminiApiKey == null + ? GeminiApiKeyPage( + title: App.title, + onApiKey: _setApiKey, + ) + : ChatPage( + geminiApiKey: _geminiApiKey!, + onResetApiKey: _resetApiKey, + ), + debugShowCheckedModeBanner: false, + ), + ); + + void _setApiKey(String apiKey) { + setState(() => _geminiApiKey = apiKey); + widget.prefs.setString('gemini_api_key', apiKey); + } + + void _resetApiKey() { + setState(() => _geminiApiKey = null); + widget.prefs.remove('gemini_api_key'); + } +} + +class ChatPage extends StatefulWidget { + const ChatPage({ + required this.geminiApiKey, + required this.onResetApiKey, + super.key, + }); + + final String geminiApiKey; + final void Function() onResetApiKey; + + @override + State createState() => _ChatPageState(); +} + +class _ChatPageState extends State + with SingleTickerProviderStateMixin { + late final _animationController = AnimationController( + duration: const Duration(seconds: 1), + vsync: this, + lowerBound: 0.25, + upperBound: 1.0, + ); + + late final _provider = GeminiProvider( + model: GenerativeModel( + model: 'gemini-1.5-flash', + apiKey: widget.geminiApiKey, + ), + ); + + final _halloweenMode = ValueNotifier(false); + + @override + void initState() { + super.initState(); + _resetAnimation(); + } + + void _resetAnimation() { + _animationController.value = 1.0; + _animationController.reverse(); + } + + @override + void dispose() { + _animationController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => ValueListenableBuilder( + valueListenable: _halloweenMode, + builder: (context, halloween, child) => Scaffold( + appBar: AppBar( + title: const Text(App.title), + actions: [ + IconButton( + onPressed: widget.onResetApiKey, + tooltip: 'Reset API Key', + icon: const Icon(Icons.key), + ), + IconButton( + onPressed: _clearHistory, + tooltip: 'Clear History', + icon: const Icon(Icons.history), + ), + IconButton( + onPressed: () => App.themeMode.value = + App.themeMode.value == ThemeMode.light + ? ThemeMode.dark + : ThemeMode.light, + tooltip: App.themeMode.value == ThemeMode.light + ? 'Dark Mode' + : 'Light Mode', + icon: const Icon(Icons.brightness_4_outlined), + ), + IconButton( + onPressed: () { + _halloweenMode.value = !_halloweenMode.value; + if (_halloweenMode.value) _resetAnimation(); + }, + tooltip: + _halloweenMode.value ? 'Normal Mode' : 'Halloween Mode', + icon: Text('🎃'), + ), + ], + ), + body: AnimatedBuilder( + animation: _animationController, + builder: (context, child) => Stack( + children: [ + SizedBox( + height: double.infinity, + width: double.infinity, + child: Image.asset( + 'assets/halloween-bg.png', + fit: BoxFit.cover, + opacity: _animationController, + ), + ), + LlmChatView( + provider: _provider, + style: style, + welcomeMessage: + 'Hello and welcome to the Flutter AI Toolkit!', + suggestions: [ + 'I\'m a Star Wars fan. What should I wear for Halloween?', + 'I\'m allergic to peanuts. What candy should I avoid at ' + 'Halloween?', + 'What\'s the difference between a pumpkin and a squash?', + ], + ), + ], + ), + ), + ), + ); + + void _clearHistory() { + _provider.history = []; + _resetAnimation(); + } + + LlmChatViewStyle get style { + if (!_halloweenMode.value) { + return App.themeMode.value == ThemeMode.dark + ? darkChatViewStyle() + : LlmChatViewStyle.defaultStyle(); + } + + // Halloween mode + final TextStyle halloweenTextStyle = GoogleFonts.hennyPenny( + color: Colors.white, + fontSize: 24, + ); + + final halloweenActionButtonStyle = ActionButtonStyle( + tooltipTextStyle: halloweenTextStyle, + iconColor: Colors.black, + iconDecoration: BoxDecoration( + color: Colors.orange, + borderRadius: BorderRadius.circular(8), + ), + ); + + final halloweenMenuButtonStyle = ActionButtonStyle( + tooltipTextStyle: halloweenTextStyle, + iconColor: Colors.orange, + iconDecoration: BoxDecoration( + color: Colors.black, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.orange), + ), + ); + + return LlmChatViewStyle( + backgroundColor: Colors.transparent, + progressIndicatorColor: Colors.purple, + suggestionStyle: SuggestionStyle( + textStyle: halloweenTextStyle.copyWith(color: Colors.black), + decoration: BoxDecoration( + color: Colors.yellow, + border: Border.all(color: Colors.orange), + ), + ), + chatInputStyle: ChatInputStyle( + backgroundColor: _animationController.isAnimating + ? Colors.transparent + : Colors.black, + decoration: BoxDecoration( + color: Colors.yellow, + border: Border.all(color: Colors.orange), + ), + textStyle: halloweenTextStyle.copyWith(color: Colors.black), + hintText: 'good evening...', + hintStyle: + halloweenTextStyle.copyWith(color: Colors.orange.withOpacity(.5)), + ), + userMessageStyle: UserMessageStyle( + textStyle: halloweenTextStyle.copyWith(color: Colors.black), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + Colors.white, + Colors.grey.shade300, + Colors.grey.shade400, + ], + ), + borderRadius: BorderRadius.circular(20), + boxShadow: [ + BoxShadow( + color: Colors.grey.withOpacity(0.5), + blurRadius: 10, + spreadRadius: 2, + ), + ], + ), + ), + llmMessageStyle: LlmMessageStyle( + icon: Icons.sentiment_very_satisfied, + iconColor: Colors.black, + iconDecoration: BoxDecoration( + color: Colors.orange, + borderRadius: BorderRadius.only( + topLeft: Radius.circular(8), + bottomLeft: Radius.circular(8), + topRight: Radius.zero, + bottomRight: Radius.circular(8), + ), + border: Border.all(color: Colors.black), + ), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + Colors.deepOrange.shade900, + Colors.orange.shade800, + Colors.purple.shade900, + ], + ), + borderRadius: BorderRadius.only( + topLeft: Radius.zero, + bottomLeft: Radius.circular(20), + topRight: Radius.circular(20), + bottomRight: Radius.circular(20), + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.3), + blurRadius: 8, + offset: Offset(2, 2), + ), + ], + ), + markdownStyle: MarkdownStyleSheet( + p: halloweenTextStyle, + listBullet: halloweenTextStyle, + ), + ), + recordButtonStyle: halloweenActionButtonStyle, + stopButtonStyle: halloweenActionButtonStyle, + submitButtonStyle: halloweenActionButtonStyle, + addButtonStyle: halloweenActionButtonStyle, + attachFileButtonStyle: halloweenMenuButtonStyle, + cameraButtonStyle: halloweenMenuButtonStyle, + closeButtonStyle: halloweenActionButtonStyle, + cancelButtonStyle: halloweenActionButtonStyle, + closeMenuButtonStyle: halloweenActionButtonStyle, + copyButtonStyle: halloweenMenuButtonStyle, + editButtonStyle: halloweenMenuButtonStyle, + galleryButtonStyle: halloweenMenuButtonStyle, + actionButtonBarDecoration: BoxDecoration( + color: Colors.orange, + borderRadius: BorderRadius.circular(8), + ), + fileAttachmentStyle: FileAttachmentStyle( + decoration: BoxDecoration( + color: Colors.black, + ), + iconDecoration: BoxDecoration( + color: Colors.orange, + borderRadius: BorderRadius.circular(8), + ), + filenameStyle: halloweenTextStyle, + filetypeStyle: halloweenTextStyle.copyWith( + color: Colors.green, + fontSize: 18, + ), + ), + ); + } +} diff --git a/example/lib/echo/echo.dart b/example/lib/echo/echo.dart new file mode 100644 index 0000000..ca8e6ba --- /dev/null +++ b/example/lib/echo/echo.dart @@ -0,0 +1,30 @@ +// Copyright 2024 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; +import 'package:flutter_ai_toolkit/flutter_ai_toolkit.dart'; + +void main() => runApp(App()); + +class App extends StatefulWidget { + static const title = 'Example: Echo Test'; + + const App({super.key}); + + @override + State createState() => _AppState(); +} + +class _AppState extends State { + final _provider = EchoProvider(); + + @override + Widget build(BuildContext context) => MaterialApp( + title: App.title, + home: Scaffold( + appBar: AppBar(title: const Text(App.title)), + body: LlmChatView(provider: _provider), + ), + ); +} diff --git a/example/lib/gemini/gemini.dart b/example/lib/gemini/gemini.dart new file mode 100644 index 0000000..2f86d26 --- /dev/null +++ b/example/lib/gemini/gemini.dart @@ -0,0 +1,40 @@ +// Copyright 2024 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; +import 'package:flutter_ai_toolkit/flutter_ai_toolkit.dart'; +import 'package:google_generative_ai/google_generative_ai.dart'; + +import '../gemini_api_key.dart'; + +void main() => runApp(const App()); + +class App extends StatelessWidget { + static const title = 'Example: Google Gemini AI'; + + const App({super.key}); + + @override + Widget build(BuildContext context) => const MaterialApp( + title: title, + home: ChatPage(), + ); +} + +class ChatPage extends StatelessWidget { + const ChatPage({super.key}); + + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar(title: const Text(App.title)), + body: LlmChatView( + provider: GeminiProvider( + model: GenerativeModel( + model: 'gemini-1.5-flash', + apiKey: geminiApiKey, + ), + ), + ), + ); +} diff --git a/example/lib/history/history.dart b/example/lib/history/history.dart new file mode 100644 index 0000000..d94af77 --- /dev/null +++ b/example/lib/history/history.dart @@ -0,0 +1,170 @@ +// Copyright 2024 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:convert'; +import 'dart:io' as io; + +import 'package:flutter/material.dart'; +import 'package:flutter_ai_toolkit/flutter_ai_toolkit.dart'; +import 'package:google_generative_ai/google_generative_ai.dart'; +import 'package:path/path.dart' as path; +import 'package:path_provider/path_provider.dart' as pp; + +import '../gemini_api_key.dart'; + +void main() => runApp(const App()); + +class App extends StatelessWidget { + static const title = 'Example: History'; + + const App({super.key}); + + @override + Widget build(BuildContext context) => MaterialApp( + title: title, + home: ChatPage(), + debugShowCheckedModeBanner: false, + ); +} + +class ChatPage extends StatefulWidget { + const ChatPage({super.key}); + + @override + State createState() => _ChatPageState(); +} + +class _ChatPageState extends State { + late final _provider = GeminiProvider( + model: GenerativeModel( + model: 'gemini-1.5-flash', + apiKey: geminiApiKey, + ), + ); + + @override + void initState() { + super.initState(); + _provider.addListener(_saveHistory); + _loadHistory(); + } + + @override + void dispose() { + _provider.removeListener(_saveHistory); + super.dispose(); + } + + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar( + title: const Text(App.title), + actions: [ + IconButton( + onPressed: _clearHistory, + icon: const Icon(Icons.history), + ), + ], + ), + body: LlmChatView( + provider: _provider, + welcomeMessage: _welcomeMessage, + ), + ); + + io.Directory? _historyDir; + + final _welcomeMessage = '# Welcome\n' + 'Hello and welcome to the chat! This sample shows off a simple way to ' + 'use the Flutter AI Toolkit to create a chat history that is saved to ' + 'disk and restored the next time the app is launched.\n\n' + '# Note\n' + '**Since this sample depends on the availability of a file system and ' + 'the ability to save and restore files, it will not work in an ' + 'environment that does not support these capabilities, such as a web ' + 'browser.**' + ''; + + Future _getHistoryDir() async { + if (_historyDir == null) { + final temp = await pp.getTemporaryDirectory(); + _historyDir = io.Directory(path.join(temp.path, 'chat-history')); + await _historyDir!.create(); + } + return _historyDir!; + } + + Future _messageFile(int messageNo) async { + final fileName = path.join( + (await _getHistoryDir()).path, + 'message-${messageNo.toString().padLeft(3, '0')}.json', + ); + return io.File(fileName); + } + + Future _loadHistory() async { + // read the history from disk + final history = []; + for (var i = 0;; ++i) { + final file = await _messageFile(i); + if (!file.existsSync()) break; + + debugPrint('Loading: ${file.path}'); + final map = jsonDecode(await file.readAsString()); + history.add(ChatMessage.fromJson(map)); + } + + // set the history on the controller + _provider.history = history; + } + + Future _saveHistory() async { + // get the latest history + final history = _provider.history.toList(); + + // write the new messages + for (var i = 0; i != history.length; ++i) { + // skip if the file already exists + final file = await _messageFile(i); + if (file.existsSync()) continue; + + // write the new message to disk + debugPrint('Saving: ${file.path}'); + final map = history[i].toJson(); + final json = JsonEncoder.withIndent(' ').convert(map); + await file.writeAsString(json); + } + } + + void _clearHistory() async { + final ok = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Clear history?'), + actions: [ + ElevatedButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('Clear'), + ), + OutlinedButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + ], + ), + ); + + if (ok != true) return; + + // delete any old messages + for (var i = 0;; ++i) { + final file = await _messageFile(i); + if (!file.existsSync()) break; + debugPrint('Deleting: ${file.path}'); + await file.delete(); + } + + _provider.history = []; + } +} diff --git a/example/lib/logging/logging.dart b/example/lib/logging/logging.dart new file mode 100644 index 0000000..c75468d --- /dev/null +++ b/example/lib/logging/logging.dart @@ -0,0 +1,64 @@ +// Copyright 2024 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; +import 'package:flutter_ai_toolkit/flutter_ai_toolkit.dart'; +import 'package:google_generative_ai/google_generative_ai.dart'; + +import '../gemini_api_key.dart'; + +void main() => runApp(const App()); + +class App extends StatelessWidget { + static const title = 'Example: Logging'; + + const App({super.key}); + + @override + Widget build(BuildContext context) => MaterialApp( + title: title, + home: ChatPage(), + ); +} + +class ChatPage extends StatelessWidget { + ChatPage({super.key}); + final _provider = GeminiProvider( + model: GenerativeModel( + model: 'gemini-1.5-flash', + apiKey: geminiApiKey, + ), + ); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text(App.title)), + body: LlmChatView( + provider: _provider, + messageSender: _logMessage, + ), + ); + } + + Stream _logMessage( + String prompt, { + required Iterable attachments, + }) async* { + // log the message and attachments + debugPrint('# Sending Message'); + debugPrint('## Prompt\n$prompt'); + debugPrint('## Attachments\n${attachments.map((a) => a.toString())}'); + + // forward the message on to the provider + final response = _provider.sendMessageStream( + prompt, + attachments: attachments, + ); + + // log the response + final text = response.join(); + debugPrint('## Response\n$text'); + } +} diff --git a/example/lib/main.dart b/example/lib/main.dart new file mode 100644 index 0000000..2f86d26 --- /dev/null +++ b/example/lib/main.dart @@ -0,0 +1,40 @@ +// Copyright 2024 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; +import 'package:flutter_ai_toolkit/flutter_ai_toolkit.dart'; +import 'package:google_generative_ai/google_generative_ai.dart'; + +import '../gemini_api_key.dart'; + +void main() => runApp(const App()); + +class App extends StatelessWidget { + static const title = 'Example: Google Gemini AI'; + + const App({super.key}); + + @override + Widget build(BuildContext context) => const MaterialApp( + title: title, + home: ChatPage(), + ); +} + +class ChatPage extends StatelessWidget { + const ChatPage({super.key}); + + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar(title: const Text(App.title)), + body: LlmChatView( + provider: GeminiProvider( + model: GenerativeModel( + model: 'gemini-1.5-flash', + apiKey: geminiApiKey, + ), + ), + ), + ); +} diff --git a/example/lib/recipes/data/recipe_data.dart b/example/lib/recipes/data/recipe_data.dart new file mode 100644 index 0000000..2823013 --- /dev/null +++ b/example/lib/recipes/data/recipe_data.dart @@ -0,0 +1,100 @@ +// Copyright 2024 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:convert'; + +import 'package:uuid/uuid.dart'; + +class Recipe { + Recipe({ + required this.id, + required this.title, + required this.description, + required this.ingredients, + required this.instructions, + this.tags = const [], + this.notes = '', + }); + + Recipe.empty(String id) + : this( + id: id, + title: '', + description: '', + ingredients: [], + instructions: [], + tags: [], + notes: '', + ); + + factory Recipe.fromJson(Map json) => Recipe( + id: json['id'] ?? const Uuid().v4(), + title: json['title'], + description: json['description'], + ingredients: List.from(json['ingredients']), + instructions: List.from(json['instructions']), + tags: json['tags'] == null ? [] : List.from(json['tags']), + notes: json['notes'] ?? '', + ); + + final String id; + final String title; + final String description; + final List ingredients; + final List instructions; + final List tags; + final String notes; + + Map toJson() => { + 'id': id, + 'title': title, + 'description': description, + 'ingredients': ingredients, + 'instructions': instructions, + 'tags': tags, + 'notes': notes, + }; + + static Future> loadFrom(String json) async { + final jsonList = jsonDecode(json) as List; + return [for (final json in jsonList) Recipe.fromJson(json)]; + } + + @override + String toString() => '''# $title +$description + +## Ingredients +${ingredients.join('\n')} + +## Instructions +${instructions.join('\n')} +'''; +} + +class RecipeEmbedding { + RecipeEmbedding({ + required this.id, + required this.embedding, + }); + + factory RecipeEmbedding.fromJson(Map json) => + RecipeEmbedding( + id: json['id'], + embedding: List.from(json['embedding']), + ); + + final String id; + final List embedding; + + static Future> loadFrom(String json) async { + final jsonList = jsonDecode(json) as List; + return [for (final json in jsonList) RecipeEmbedding.fromJson(json)]; + } + + Map toJson() => { + 'id': id, + 'embedding': embedding, + }; +} diff --git a/example/lib/recipes/data/recipe_repository.dart b/example/lib/recipes/data/recipe_repository.dart new file mode 100644 index 0000000..ac32802 --- /dev/null +++ b/example/lib/recipes/data/recipe_repository.dart @@ -0,0 +1,98 @@ +// Copyright 2024 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; +import 'dart:convert'; +import 'dart:io' as io; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart' show rootBundle; +import 'package:path/path.dart' as path; +import 'package:path_provider/path_provider.dart' as pp; + +import 'recipe_data.dart'; + +class RecipeRepository { + static const newRecipeID = '__NEW_RECIPE__'; + static const _fileName = 'recipes.json'; + + static const _assetFileName = 'assets/recipes_default.json'; + + static List? _recipes; + static final items = ValueNotifier>([]); + + static Future init() async { + assert(_recipes == null, 'call init() only once'); + _recipes = await _loadRecipes(); + items.value = _recipes!; + } + + static Iterable get recipes { + assert(_recipes != null, 'call init() first'); + return _recipes!; + } + + static Recipe getRecipe(String recipeId) { + assert(_recipes != null, 'call init() first'); + if (recipeId == newRecipeID) return Recipe.empty(newRecipeID); + return _recipes!.singleWhere((r) => r.id == recipeId); + } + + static Future addNewRecipe(Recipe newRecipe) async { + assert(_recipes != null, 'call init() first'); + _recipes!.add(newRecipe); + await _saveRecipes(); + } + + static Future updateRecipe(Recipe recipe) async { + assert(_recipes != null, 'call init() first'); + final i = _recipes!.indexWhere((r) => r.id == recipe.id); + assert(i >= 0); + _recipes![i] = recipe; + await _saveRecipes(); + } + + static Future deleteRecipe(Recipe recipe) async { + assert(_recipes != null, 'call init() first'); + final removed = _recipes!.remove(recipe); + assert(removed); + await _saveRecipes(); + } + + static Future get _recipeFile async { + final directory = await pp.getApplicationSupportDirectory(); + return io.File(path.join(directory.path, _fileName)); + } + + static Future> _loadRecipes() async { + // seed empty recipe file w/ sample recipes; note: we're not loading from a + // file on the web; all recipes are in memory for the sessions only + late final String contents; + if (!kIsWeb) { + final recipeFile = await _recipeFile; + contents = await recipeFile.exists() + ? await recipeFile.readAsString() + : await rootBundle.loadString(_assetFileName); + } else { + contents = await rootBundle.loadString(_assetFileName); + } + + final jsonList = json.decode(contents) as List; + return jsonList.map((json) => Recipe.fromJson(json)).toList(); + } + + static Future _saveRecipes() async { + // note: we're not saving to a file on the web; all recipes are in memory + // for the sessions only + if (!kIsWeb) { + final file = await _recipeFile; + final jsonString = json.encode(recipes.map((r) => r.toJson()).toList()); + await file.writeAsString(jsonString); + } + + // notify listeners that the recipes have changed + items.value = []; + items.value = _recipes!; + } +} diff --git a/example/lib/recipes/data/settings.dart b/example/lib/recipes/data/settings.dart new file mode 100644 index 0000000..91ea1a3 --- /dev/null +++ b/example/lib/recipes/data/settings.dart @@ -0,0 +1,24 @@ +import 'package:shared_preferences/shared_preferences.dart'; + +class Settings { + Settings._(); + + static SharedPreferencesWithCache? _prefs; + + static Future init() async { + assert(_prefs == null, 'call Settings.init() exactly once'); + _prefs = await SharedPreferencesWithCache.create( + cacheOptions: SharedPreferencesWithCacheOptions(), + ); + } + + static String get foodPreferences { + assert(_prefs != null, 'call Settings.init() exactly once'); + return _prefs!.getString('foodPreferences') ?? ''; + } + + static Future setFoodPreferences(String value) async { + assert(_prefs != null, 'call Settings.init() exactly once'); + await _prefs!.setString('foodPreferences', value); + } +} diff --git a/example/lib/recipes/pages/edit_recipe_page.dart b/example/lib/recipes/pages/edit_recipe_page.dart new file mode 100644 index 0000000..62a557e --- /dev/null +++ b/example/lib/recipes/pages/edit_recipe_page.dart @@ -0,0 +1,279 @@ +// Copyright 2024 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter_ai_toolkit/flutter_ai_toolkit.dart'; +import 'package:gap/gap.dart'; +import 'package:go_router/go_router.dart'; +import 'package:google_generative_ai/google_generative_ai.dart'; +import 'package:uuid/uuid.dart'; + +import '../../gemini_api_key.dart'; +import '../data/recipe_data.dart'; +import '../data/recipe_repository.dart'; +import '../data/settings.dart'; + +class EditRecipePage extends StatefulWidget { + const EditRecipePage({ + super.key, + required this.recipe, + }); + + final Recipe recipe; + + @override + _EditRecipePageState createState() => _EditRecipePageState(); +} + +class _EditRecipePageState extends State { + final _formKey = GlobalKey(); + late final TextEditingController _titleController; + late final TextEditingController _descriptionController; + late final TextEditingController _ingredientsController; + late final TextEditingController _instructionsController; + + final _provider = GeminiProvider( + model: GenerativeModel( + model: "gemini-1.5-flash", + apiKey: geminiApiKey, + generationConfig: GenerationConfig( + responseMimeType: 'application/json', + responseSchema: Schema( + SchemaType.object, + properties: { + 'modifications': Schema( + description: 'The modifications to the recipe you made', + SchemaType.string, + ), + 'recipe': Schema( + SchemaType.object, + properties: { + 'title': Schema(SchemaType.string), + 'description': Schema(SchemaType.string), + 'ingredients': Schema( + SchemaType.array, + items: Schema(SchemaType.string), + ), + 'instructions': Schema( + SchemaType.array, + items: Schema(SchemaType.string), + ), + }, + ), + }, + ), + ), + systemInstruction: Content.system( + ''' +You are a helpful assistant that generates recipes based on the ingredients and +instructions provided: +${Settings.foodPreferences.isEmpty ? 'I don\'t have any food preferences' : Settings.foodPreferences} + +When you generate a recipe, you should generate a JSON object. +''', + ), + ), + ); + + @override + void initState() { + super.initState(); + + _titleController = TextEditingController( + text: widget.recipe.title, + ); + _descriptionController = TextEditingController( + text: widget.recipe.description, + ); + _ingredientsController = TextEditingController( + text: widget.recipe.ingredients.join('\n'), + ); + _instructionsController = TextEditingController( + text: widget.recipe.instructions.join('\n'), + ); + } + + @override + void dispose() { + _titleController.dispose(); + _descriptionController.dispose(); + _ingredientsController.dispose(); + _instructionsController.dispose(); + super.dispose(); + } + + bool get _isNewRecipe => widget.recipe.id == RecipeRepository.newRecipeID; + + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar(title: Text('${_isNewRecipe ? "Add" : "Edit"} Recipe')), + body: Form( + key: _formKey, + child: SingleChildScrollView( + padding: const EdgeInsets.all(16.0), + child: Column( + children: [ + TextFormField( + controller: _titleController, + decoration: const InputDecoration( + labelText: 'Title', + hintText: 'Enter a name for your recipe...', + ), + validator: (value) => (value == null || value.isEmpty) + ? 'Recipe title is requires' + : null, + ), + TextField( + controller: _descriptionController, + decoration: const InputDecoration( + labelText: 'Description', + hintText: 'In a few words, describe your recipe...', + ), + maxLines: null, + ), + TextField( + controller: _ingredientsController, + decoration: const InputDecoration( + labelText: 'Ingredients🍎 (one per line)', + hintText: 'e.g., 2 cups flour\n1 tsp salt\n1 cup sugar', + ), + maxLines: null, + ), + TextField( + controller: _instructionsController, + decoration: const InputDecoration( + labelText: 'Instructions🥧 (one per line)', + hintText: 'e.g., Mix ingredients\nBake for 30 minutes', + ), + maxLines: null, + ), + const Gap(16), + OverflowBar( + spacing: 16, + children: [ + ElevatedButton( + onPressed: _onMagic, + child: const Text('Magic'), + ), + OutlinedButton( + onPressed: _onDone, + child: const Text('Done'), + ), + ], + ), + ], + ), + ), + ), + ); + + void _onDone() { + if (!_formKey.currentState!.validate()) return; + + final recipe = Recipe( + id: _isNewRecipe ? const Uuid().v4() : widget.recipe.id, + title: _titleController.text, + description: _descriptionController.text, + ingredients: _ingredientsController.text.split('\n'), + instructions: _instructionsController.text.split('\n'), + ); + + if (_isNewRecipe) { + RecipeRepository.addNewRecipe(recipe); + } else { + RecipeRepository.updateRecipe(recipe); + } + + if (context.mounted) context.goNamed('home'); + } + + Future _onMagic() async { + final stream = _provider.sendMessageStream( + 'Generate a modified version of this recipe based on my food preferences: ' + '${_ingredientsController.text}\n\n${_instructionsController.text}', + ); + var response = await stream.join(); + final json = jsonDecode(response); + + try { + final modifications = json['modifications']; + final recipe = Recipe.fromJson(json['recipe']); + + if (!context.mounted) return; + final accept = await showDialog( + // ignore: use_build_context_synchronously + context: context, + builder: (context) => AlertDialog( + title: Text(recipe.title), + content: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Modifications:'), + const Gap(16), + Text(_wrapText(modifications)), + ], + ), + actions: [ + TextButton( + onPressed: () => context.pop(true), + child: const Text('Accept'), + ), + TextButton( + onPressed: () => context.pop(false), + child: const Text('Reject'), + ), + ], + ), + ); + + if (accept == true) { + setState(() { + _titleController.text = recipe.title; + _descriptionController.text = recipe.description; + _ingredientsController.text = recipe.ingredients.join('\n'); + _instructionsController.text = recipe.instructions.join('\n'); + }); + } + } catch (ex) { + if (context.mounted) { + showDialog( + // ignore: use_build_context_synchronously + context: context, + builder: (context) => AlertDialog( + title: const Text('Error'), + content: Text(ex.toString()), + actions: [ + TextButton( + onPressed: () => context.pop(), + child: const Text('OK'), + ), + ], + ), + ); + } + } + } + + String _wrapText(String text, {int lineLength = 80}) { + final words = text.split(RegExp(r'\s+')); + final lines = []; + + var currentLine = ''; + for (final word in words) { + if (currentLine.isEmpty) { + currentLine = word; + } else if (('$currentLine $word').length <= lineLength) { + currentLine += ' $word'; + } else { + lines.add(currentLine); + currentLine = word; + } + } + + if (currentLine.isNotEmpty) lines.add(currentLine); + return lines.join('\n'); + } +} diff --git a/example/lib/recipes/pages/home_page.dart b/example/lib/recipes/pages/home_page.dart new file mode 100644 index 0000000..7172bca --- /dev/null +++ b/example/lib/recipes/pages/home_page.dart @@ -0,0 +1,165 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_ai_toolkit/flutter_ai_toolkit.dart'; +import 'package:go_router/go_router.dart'; +import 'package:google_generative_ai/google_generative_ai.dart'; + +import '../../gemini_api_key.dart'; +import '../data/recipe_repository.dart'; +import '../data/settings.dart'; +import '../views/recipe_list_view.dart'; +import '../views/recipe_response_view.dart'; +import '../views/search_box.dart'; +import '../views/settings_drawer.dart'; +import 'split_or_tabs.dart'; + +class HomePage extends StatefulWidget { + const HomePage({super.key}); + + @override + _HomePageState createState() => _HomePageState(); +} + +class _HomePageState extends State { + String _searchText = ''; + + late LlmProvider _provider = _createProvider(); + + // create a new provider with the given history and the current settings + LlmProvider _createProvider([List? history]) => GeminiProvider( + history: history, + model: GenerativeModel( + model: 'gemini-1.5-flash', //'gemini-1.5-pro', + apiKey: geminiApiKey, + generationConfig: GenerationConfig( + responseMimeType: 'application/json', + responseSchema: Schema( + SchemaType.object, + properties: { + 'recipes': Schema( + SchemaType.array, + items: Schema( + SchemaType.object, + properties: { + 'text': Schema(SchemaType.string), + 'recipe': Schema( + SchemaType.object, + properties: { + 'title': Schema(SchemaType.string), + 'description': Schema(SchemaType.string), + 'ingredients': Schema( + SchemaType.array, + items: Schema(SchemaType.string), + ), + 'instructions': Schema( + SchemaType.array, + items: Schema(SchemaType.string), + ), + }, + requiredProperties: [ + 'title', + 'description', + 'ingredients', + 'instructions', + ], + ), + }, + requiredProperties: [ + 'recipe', + ], + ), + ), + 'text': Schema(SchemaType.string), + }, + requiredProperties: [ + 'recipes', + ], + ), + ), + systemInstruction: Content.system( + ''' +You are a helpful assistant that generates recipes based on the ingredients and +instructions provided as well as my food preferences, which are as follows: +${Settings.foodPreferences.isEmpty ? 'I don\'t have any food preferences' : Settings.foodPreferences} + +You should keep things casual and friendly. You may generate multiple recipes in +a single response, but only if asked. Generate each response in JSON format +with the following schema, including one or more "text" and "recipe" pairs as +well as any trailing text commentary you care to provide: + +{ + "recipes": [ + { + "text": "Any commentary you care to provide about the recipe.", + "recipe": + { + "title": "Recipe Title", + "description": "Recipe Description", + "ingredients": ["Ingredient 1", "Ingredient 2", "Ingredient 3"], + "instructions": ["Instruction 1", "Instruction 2", "Instruction 3"] + } + } + ], + "text": "any final commentary you care to provide", +} +''', + ), + ), + ); + + final _welcomeMessage = + 'Hello and welcome to the Recipes sample app!\n\nIn this app, you can ' + 'generate recipes based on the ingredients and instructions provided ' + 'as well as your food preferences.\n\nIt also demonstrates several ' + 'real-world use cases for the Flutter AI Toolkit.\n\nEnjoy!'; + + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar( + title: const Text('Example: Recipes'), + actions: [ + IconButton( + onPressed: _onAdd, + tooltip: 'Add Recipe', + icon: const Icon(Icons.add), + ), + ], + ), + drawer: Builder( + builder: (context) => SettingsDrawer(onSave: _onSettingsSave), + ), + body: SplitOrTabs( + tabs: const [ + Tab(text: 'Recipes'), + Tab(text: 'Chat'), + ], + children: [ + Column( + children: [ + SearchBox(onSearchChanged: _updateSearchText), + Expanded(child: RecipeListView(searchText: _searchText)), + ], + ), + LlmChatView( + provider: _provider, + welcomeMessage: _welcomeMessage, + responseBuilder: (context, response) => RecipeResponseView( + response, + ), + ), + ], + ), + ); + + void _updateSearchText(String text) => setState(() => _searchText = text); + + void _onAdd() => context.goNamed( + 'edit', + pathParameters: {'recipe': RecipeRepository.newRecipeID}, + ); + + void _onSettingsSave() => setState(() { + // move the history over from the old provider to the new one + final history = _provider.history.toList(); + _provider = _createProvider(history); + }); +} diff --git a/example/lib/recipes/pages/split_or_tabs.dart b/example/lib/recipes/pages/split_or_tabs.dart new file mode 100644 index 0000000..b5d63ee --- /dev/null +++ b/example/lib/recipes/pages/split_or_tabs.dart @@ -0,0 +1,64 @@ +import 'package:flutter/material.dart'; +import 'package:split_view/split_view.dart'; + +class SplitOrTabs extends StatefulWidget { + const SplitOrTabs({ + required this.tabs, + required this.children, + super.key, + }); + final List tabs; + final List children; + + @override + State createState() => _SplitOrTabsState(); +} + +class _SplitOrTabsState extends State + with SingleTickerProviderStateMixin { + late TabController _tabController; + + @override + void initState() { + super.initState(); + _tabController = TabController(length: widget.tabs.length, vsync: this); + } + + @override + void dispose() { + _tabController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => MediaQuery.of(context).size.width > 600 + ? SplitView( + viewMode: SplitViewMode.Horizontal, + gripColor: Colors.transparent, + indicator: SplitIndicator( + viewMode: SplitViewMode.Horizontal, + color: Colors.grey, + ), + gripColorActive: Colors.transparent, + activeIndicator: SplitIndicator( + viewMode: SplitViewMode.Horizontal, + isActive: true, + color: Colors.black, + ), + children: widget.children, + ) + : Column( + children: [ + TabBar( + controller: _tabController, + tabs: widget.tabs, + ), + Expanded( + child: TabBarView( + controller: _tabController, + children: widget.children, + ), + ), + ], + ); +} diff --git a/example/lib/recipes/recipes.dart b/example/lib/recipes/recipes.dart new file mode 100644 index 0000000..aee85c5 --- /dev/null +++ b/example/lib/recipes/recipes.dart @@ -0,0 +1,47 @@ +// Copyright 2024 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +import 'data/recipe_repository.dart'; +import 'data/settings.dart'; +import 'pages/edit_recipe_page.dart'; +import 'pages/home_page.dart'; + +void main() async { + WidgetsFlutterBinding.ensureInitialized(); + await Settings.init(); + await RecipeRepository.init(); + runApp(App()); +} + +class App extends StatelessWidget { + App({super.key}); + + final _router = GoRouter( + routes: [ + GoRoute( + name: 'home', + path: '/', + builder: (BuildContext context, _) => const HomePage(), + routes: [ + GoRoute( + name: 'edit', + path: 'edit/:recipe', + builder: (context, state) { + final recipeId = state.pathParameters['recipe']!; + final recipe = RecipeRepository.getRecipe(recipeId); + return EditRecipePage(recipe: recipe); + }, + ), + ], + ), + ], + ); + + @override + Widget build(BuildContext context) => + MaterialApp.router(routerConfig: _router); +} diff --git a/example/lib/recipes/views/recipe_content_view.dart b/example/lib/recipes/views/recipe_content_view.dart new file mode 100644 index 0000000..2028b38 --- /dev/null +++ b/example/lib/recipes/views/recipe_content_view.dart @@ -0,0 +1,84 @@ +// Copyright 2024 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; +import 'package:gap/gap.dart'; + +import '../data/recipe_data.dart'; + +class RecipeContentView extends StatelessWidget { + const RecipeContentView({ + super.key, + required this.recipe, + }); + + final Recipe recipe; + static const mobileBreakpoint = 600; + + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.all(16), + child: LayoutBuilder( + builder: (context, constraints) => + constraints.maxWidth < mobileBreakpoint + ? SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _RecipeIngredientsView(recipe), + const Gap(16), + _RecipeInstructionsView(recipe), + ], + ), + ) + : Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(child: _RecipeIngredientsView(recipe)), + const Gap(16), + Expanded(child: _RecipeInstructionsView(recipe)), + ], + ), + ), + ); +} + +class _RecipeIngredientsView extends StatelessWidget { + const _RecipeIngredientsView(this.recipe); + final Recipe recipe; + + @override + Widget build(BuildContext context) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Ingredients:🍎', + style: Theme.of(context).textTheme.titleMedium, + ), + ...[ + for (final ingredient in recipe.ingredients) Text('• $ingredient') + ], + ], + ); +} + +class _RecipeInstructionsView extends StatelessWidget { + const _RecipeInstructionsView(this.recipe); + final Recipe recipe; + + @override + Widget build(BuildContext context) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Instructions:🥧', + style: Theme.of(context).textTheme.titleMedium, + ), + ...[ + for (final entry in recipe.instructions.asMap().entries) + Text('${entry.key + 1}. ${entry.value}') + ], + ], + ); +} diff --git a/example/lib/recipes/views/recipe_list_view.dart b/example/lib/recipes/views/recipe_list_view.dart new file mode 100644 index 0000000..4937ef5 --- /dev/null +++ b/example/lib/recipes/views/recipe_list_view.dart @@ -0,0 +1,97 @@ +// Copyright 2024 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +import '../data/recipe_data.dart'; +import '../data/recipe_repository.dart'; +import 'recipe_view.dart'; + +class RecipeListView extends StatefulWidget { + final String searchText; + + const RecipeListView({super.key, required this.searchText}); + + @override + _RecipeListViewState createState() => _RecipeListViewState(); +} + +class _RecipeListViewState extends State { + final _expanded = {}; + + Iterable _filteredRecipes(Iterable recipes) => recipes + .where((recipe) => + recipe.title + .toLowerCase() + .contains(widget.searchText.toLowerCase()) || + recipe.description + .toLowerCase() + .contains(widget.searchText.toLowerCase()) || + recipe.tags.any((tag) => + tag.toLowerCase().contains(widget.searchText.toLowerCase()))) + .toList() + ..sort((a, b) => a.title.toLowerCase().compareTo(b.title.toLowerCase())); + + @override + Widget build(BuildContext context) => + ValueListenableBuilder?>( + valueListenable: RecipeRepository.items, + builder: (context, recipes, child) { + if (recipes == null) { + return const Center(child: CircularProgressIndicator()); + } + + final displayedRecipes = _filteredRecipes(recipes).toList(); + return ListView.builder( + itemCount: displayedRecipes.length, + itemBuilder: (context, index) { + final recipe = displayedRecipes[index]; + final recipeId = recipe.id; + return RecipeView( + key: ValueKey(recipeId), + recipe: recipe, + expanded: _expanded[recipeId] == true, + onExpansionChanged: (expanded) => + _onExpand(recipe.id, expanded), + onEdit: () => _onEdit(recipe), + onDelete: () => _onDelete(recipe), + ); + }, + ); + }, + ); + + void _onExpand(String recipeId, bool expanded) => + _expanded[recipeId] = expanded; + + void _onEdit(Recipe recipe) => context.goNamed( + 'edit', + pathParameters: {'recipe': recipe.id}, + ); + + void _onDelete(Recipe recipe) async { + final shouldDelete = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Delete Recipe'), + content: Text( + 'Are you sure you want to delete the recipe "${recipe.title}"?', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('Delete'), + ), + ], + ), + ); + + if (shouldDelete == true) await RecipeRepository.deleteRecipe(recipe); + } +} diff --git a/example/lib/recipes/views/recipe_response_view.dart b/example/lib/recipes/views/recipe_response_view.dart new file mode 100644 index 0000000..7dcfd16 --- /dev/null +++ b/example/lib/recipes/views/recipe_response_view.dart @@ -0,0 +1,80 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter_markdown/flutter_markdown.dart'; +import 'package:gap/gap.dart'; + +import '../data/recipe_data.dart'; +import '../data/recipe_repository.dart'; +import 'recipe_content_view.dart'; + +class RecipeResponseView extends StatelessWidget { + const RecipeResponseView(this.response, {super.key}); + + final String response; + + @override + Widget build(BuildContext context) { + final children = []; + String? finalText; + + // created with the response from the LLM as the response streams in, so + // many not be a complete response yet + try { + final map = jsonDecode(response); + final recipesWithText = map['recipes'] as List; + finalText = map['text'] as String?; + + for (final recipeWithText in recipesWithText) { + // extract the text before the recipe + final text = recipeWithText['text'] as String?; + if (text != null && text.isNotEmpty) { + children.add(MarkdownBody(data: text)); + } + + // extract the recipe + final json = recipeWithText['recipe'] as Map; + final recipe = Recipe.fromJson(json); + children.add(const Gap(16)); + children.add(Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(recipe.title, style: Theme.of(context).textTheme.titleLarge), + Text(recipe.description), + RecipeContentView(recipe: recipe), + ], + )); + + // add a button to add the recipe to the list + children.add(const Gap(16)); + children.add(OutlinedButton( + onPressed: () => RecipeRepository.addNewRecipe(recipe), + child: const Text('Add Recipe'), + )); + children.add(const Gap(16)); + } + } catch (e) { + debugPrint('Error parsing response: $e'); + } + + if (children.isEmpty) { + try { + final map = jsonDecode(response); + finalText = map['text'] as String?; + } catch (e) { + debugPrint('Error parsing response: $e'); + finalText = response; + } + } + + // add the remaining text + if (finalText != null && finalText.isNotEmpty) { + children.add(MarkdownBody(data: finalText)); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: children, + ); + } +} diff --git a/example/lib/recipes/views/recipe_view.dart b/example/lib/recipes/views/recipe_view.dart new file mode 100644 index 0000000..cd05cb5 --- /dev/null +++ b/example/lib/recipes/views/recipe_view.dart @@ -0,0 +1,61 @@ +// Copyright 2024 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; +import 'package:gap/gap.dart'; + +import '../data/recipe_data.dart'; +import 'recipe_content_view.dart'; + +class RecipeView extends StatelessWidget { + const RecipeView({ + required this.recipe, + required this.expanded, + required this.onExpansionChanged, + required this.onEdit, + required this.onDelete, + super.key, + }); + + final Recipe recipe; + final bool expanded; + final ValueChanged? onExpansionChanged; + final Function() onEdit; + final Function() onDelete; + + @override + Widget build(BuildContext context) => Card( + child: Column( + children: [ + ExpansionTile( + title: Text(recipe.title), + subtitle: Text(recipe.description), + initiallyExpanded: expanded, + onExpansionChanged: onExpansionChanged, + children: [ + RecipeContentView(recipe: recipe), + Padding( + padding: const EdgeInsets.all(8.0), + child: OverflowBar( + spacing: 8, + alignment: MainAxisAlignment.end, + children: [ + ElevatedButton( + onPressed: onDelete, + child: const Text('Delete'), + ), + OutlinedButton( + onPressed: onEdit, + child: const Text('Edit'), + ), + ], + ), + ), + const Gap(16), + ], + ), + ], + ), + ); +} diff --git a/example/lib/recipes/views/search_box.dart b/example/lib/recipes/views/search_box.dart new file mode 100644 index 0000000..0f660d4 --- /dev/null +++ b/example/lib/recipes/views/search_box.dart @@ -0,0 +1,45 @@ +// Copyright 2024 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; + +class SearchBox extends StatefulWidget { + final Function(String) onSearchChanged; + + const SearchBox({super.key, required this.onSearchChanged}); + + @override + _SearchBoxState createState() => _SearchBoxState(); +} + +class _SearchBoxState extends State + with AutomaticKeepAliveClientMixin { + @override + bool get wantKeepAlive => true; + + final TextEditingController _searchController = TextEditingController(); + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + super.build(context); + return Padding( + padding: const EdgeInsets.all(8), + child: TextField( + controller: _searchController, + decoration: const InputDecoration( + labelText: 'Search recipes', + border: OutlineInputBorder(), + suffixIcon: Icon(Icons.search), + ), + onChanged: widget.onSearchChanged, + ), + ); + } +} diff --git a/example/lib/recipes/views/settings_drawer.dart b/example/lib/recipes/views/settings_drawer.dart new file mode 100644 index 0000000..3e4f649 --- /dev/null +++ b/example/lib/recipes/views/settings_drawer.dart @@ -0,0 +1,65 @@ +import 'package:flutter/material.dart'; + +import '../data/settings.dart'; + +class SettingsDrawer extends StatelessWidget { + SettingsDrawer({super.key, required this.onSave}); + final VoidCallback onSave; + + final controller = TextEditingController( + text: Settings.foodPreferences, + ); + + @override + Widget build(BuildContext context) => Drawer( + child: ListView( + children: [ + const DrawerHeader(child: Text('Food Preferences')), + Padding( + padding: const EdgeInsets.all(8.0), + child: TextField( + controller: controller, + maxLines: 5, + decoration: const InputDecoration( + hintText: 'Enter your food preferences...', + border: OutlineInputBorder( + borderSide: BorderSide(width: 1), + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide(width: 1), + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide(width: 1), + ), + ), + ), + ), + Align( + alignment: Alignment.centerRight, + child: Padding( + padding: const EdgeInsets.all(8.0), + child: OverflowBar( + spacing: 8, + children: [ + ElevatedButton( + child: const Text('Cancel'), + onPressed: () { + Navigator.of(context).pop(); + }, + ), + OutlinedButton( + child: const Text('Save'), + onPressed: () { + Settings.setFoodPreferences(controller.text); + Navigator.of(context).pop(); + onSave(); + }, + ), + ], + ), + ), + ), + ], + ), + ); +} diff --git a/example/lib/styles/styles.dart b/example/lib/styles/styles.dart new file mode 100644 index 0000000..3cdf639 --- /dev/null +++ b/example/lib/styles/styles.dart @@ -0,0 +1,238 @@ +// Copyright 2024 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; +import 'package:flutter_ai_toolkit/flutter_ai_toolkit.dart'; +import 'package:flutter_markdown/flutter_markdown.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:google_generative_ai/google_generative_ai.dart'; + +import '../gemini_api_key.dart'; + +void main() => runApp(const App()); + +class App extends StatelessWidget { + static const title = 'Example: Custom Styles'; + const App({super.key}); + + @override + Widget build(BuildContext context) => MaterialApp( + title: title, + theme: ThemeData.from( + colorScheme: ColorScheme.fromSeed(seedColor: Colors.orange), + ), + debugShowCheckedModeBanner: false, + home: ChatPage(), + ); +} + +class ChatPage extends StatefulWidget { + const ChatPage({super.key}); + + @override + State createState() => _ChatPageState(); +} + +class _ChatPageState extends State + with SingleTickerProviderStateMixin { + LlmProvider? _provider; + late final _controller = AnimationController( + duration: const Duration(seconds: 1), + vsync: this, + lowerBound: 0.25, + upperBound: 1.0, + ); + + @override + void initState() { + super.initState(); + reset(); + } + + void reset() { + _provider = GeminiProvider( + model: GenerativeModel( + model: 'gemini-1.5-flash', + apiKey: geminiApiKey, + ), + ); + _controller.value = 1.0; + _controller.reverse(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final TextStyle halloweenTextStyle = GoogleFonts.hennyPenny( + color: Colors.white, + fontSize: 24, + ); + + final halloweenActionButtonStyle = ActionButtonStyle( + tooltipTextStyle: halloweenTextStyle, + iconColor: Colors.black, + iconDecoration: BoxDecoration( + color: Colors.orange, + borderRadius: BorderRadius.circular(8), + ), + ); + + final halloweenMenuButtonStyle = ActionButtonStyle( + tooltipTextStyle: halloweenTextStyle, + iconColor: Colors.orange, + iconDecoration: BoxDecoration( + color: Colors.black, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.orange), + ), + ); + + return Scaffold( + appBar: AppBar( + title: const Text(App.title), + actions: [ + IconButton( + onPressed: reset, + icon: const Icon(Icons.edit_note), + ), + ], + ), + body: AnimatedBuilder( + animation: _controller, + builder: (context, child) => Stack( + children: [ + SizedBox( + height: double.infinity, + width: double.infinity, + child: Image.asset( + 'assets/halloween-bg.png', + fit: BoxFit.cover, + opacity: _controller, + ), + ), + LlmChatView( + provider: _provider!, + style: LlmChatViewStyle( + backgroundColor: Colors.transparent, + progressIndicatorColor: Colors.purple, + chatInputStyle: ChatInputStyle( + backgroundColor: _controller.isAnimating + ? Colors.transparent + : Colors.black, + decoration: BoxDecoration( + color: Colors.yellow, + border: Border.all(color: Colors.orange), + ), + textStyle: halloweenTextStyle.copyWith(color: Colors.black), + hintText: 'good evening...', + hintStyle: halloweenTextStyle.copyWith( + color: Colors.orange.withOpacity(.5)), + ), + userMessageStyle: UserMessageStyle( + textStyle: halloweenTextStyle.copyWith(color: Colors.black), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + Colors.white, + Colors.grey.shade300, + Colors.grey.shade400, + ], + ), + borderRadius: BorderRadius.circular(20), + boxShadow: [ + BoxShadow( + color: Colors.grey.withOpacity(0.5), + blurRadius: 10, + spreadRadius: 2, + ), + ], + ), + ), + llmMessageStyle: LlmMessageStyle( + icon: Icons.sentiment_very_satisfied, + iconColor: Colors.black, + iconDecoration: BoxDecoration( + color: Colors.orange, + borderRadius: BorderRadius.only( + topLeft: Radius.circular(8), + bottomLeft: Radius.circular(8), + topRight: Radius.zero, + bottomRight: Radius.circular(8), + ), + border: Border.all(color: Colors.black), + ), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + Colors.deepOrange.shade900, + Colors.orange.shade800, + Colors.purple.shade900, + ], + ), + borderRadius: BorderRadius.only( + topLeft: Radius.zero, + bottomLeft: Radius.circular(20), + topRight: Radius.circular(20), + bottomRight: Radius.circular(20), + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.3), + blurRadius: 8, + offset: Offset(2, 2), + ), + ], + ), + markdownStyle: MarkdownStyleSheet( + p: halloweenTextStyle, + listBullet: halloweenTextStyle, + ), + ), + recordButtonStyle: halloweenActionButtonStyle, + stopButtonStyle: halloweenActionButtonStyle, + submitButtonStyle: halloweenActionButtonStyle, + addButtonStyle: halloweenActionButtonStyle, + attachFileButtonStyle: halloweenMenuButtonStyle, + cameraButtonStyle: halloweenMenuButtonStyle, + closeButtonStyle: halloweenActionButtonStyle, + cancelButtonStyle: halloweenActionButtonStyle, + closeMenuButtonStyle: halloweenActionButtonStyle, + copyButtonStyle: halloweenMenuButtonStyle, + editButtonStyle: halloweenMenuButtonStyle, + galleryButtonStyle: halloweenMenuButtonStyle, + actionButtonBarDecoration: BoxDecoration( + color: Colors.orange, + borderRadius: BorderRadius.circular(8), + ), + fileAttachmentStyle: FileAttachmentStyle( + decoration: BoxDecoration( + color: Colors.black, + ), + iconDecoration: BoxDecoration( + color: Colors.orange, + borderRadius: BorderRadius.circular(8), + ), + filenameStyle: halloweenTextStyle, + filetypeStyle: halloweenTextStyle.copyWith( + color: Colors.green, + fontSize: 18, + ), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/example/lib/suggestions/suggestions.dart b/example/lib/suggestions/suggestions.dart new file mode 100644 index 0000000..53673c3 --- /dev/null +++ b/example/lib/suggestions/suggestions.dart @@ -0,0 +1,63 @@ +// Copyright 2024 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; +import 'package:flutter_ai_toolkit/flutter_ai_toolkit.dart'; +import 'package:google_generative_ai/google_generative_ai.dart'; + +import '../gemini_api_key.dart'; + +void main() => runApp(const App()); + +class App extends StatelessWidget { + static const title = 'Example: Suggestions'; + + const App({super.key}); + + @override + Widget build(BuildContext context) => MaterialApp( + title: title, + home: ChatPage(), + debugShowCheckedModeBanner: false, + ); +} + +class ChatPage extends StatefulWidget { + const ChatPage({super.key}); + + @override + State createState() => _ChatPageState(); +} + +class _ChatPageState extends State { + final _provider = GeminiProvider( + model: GenerativeModel( + model: 'gemini-1.5-flash', + apiKey: geminiApiKey, + ), + ); + + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar( + title: const Text(App.title), + actions: [ + IconButton( + onPressed: _clearHistory, + icon: const Icon(Icons.history), + ), + ], + ), + body: LlmChatView( + provider: _provider, + suggestions: const [ + 'Tell me a joke.', + 'Write me a limerick.', + 'Perform a haiku.', + ], + ), + ); + + void _clearHistory() => _provider.history = []; +} diff --git a/example/lib/vertex/vertex.dart b/example/lib/vertex/vertex.dart new file mode 100644 index 0000000..e8cf732 --- /dev/null +++ b/example/lib/vertex/vertex.dart @@ -0,0 +1,44 @@ +// Copyright 2024 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:firebase_core/firebase_core.dart'; +import 'package:firebase_vertexai/firebase_vertexai.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_ai_toolkit/flutter_ai_toolkit.dart'; + +// from `flutterfire config`: https://firebase.google.com/docs/flutter/setup +import '../firebase_options.dart'; + +void main() async { + WidgetsFlutterBinding.ensureInitialized(); + await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform); + runApp(const App()); +} + +class App extends StatelessWidget { + static const title = 'Example: Firebase Vertex AI'; + + const App({super.key}); + @override + Widget build(BuildContext context) => const MaterialApp( + title: title, + home: ChatPage(), + ); +} + +class ChatPage extends StatelessWidget { + const ChatPage({super.key}); + + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar(title: const Text(App.title)), + body: LlmChatView( + provider: VertexProvider( + model: FirebaseVertexAI.instance.generativeModel( + model: 'gemini-1.5-flash', + ), + ), + ), + ); +} diff --git a/example/lib/welcome/welcome.dart b/example/lib/welcome/welcome.dart new file mode 100644 index 0000000..39c5071 --- /dev/null +++ b/example/lib/welcome/welcome.dart @@ -0,0 +1,41 @@ +// Copyright 2024 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; +import 'package:flutter_ai_toolkit/flutter_ai_toolkit.dart'; +import 'package:google_generative_ai/google_generative_ai.dart'; + +import '../gemini_api_key.dart'; + +void main() => runApp(const App()); + +class App extends StatelessWidget { + static const title = 'Example: Welcome Message'; + + const App({super.key}); + + @override + Widget build(BuildContext context) => const MaterialApp( + title: title, + home: ChatPage(), + ); +} + +class ChatPage extends StatelessWidget { + const ChatPage({super.key}); + + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar(title: const Text(App.title)), + body: LlmChatView( + welcomeMessage: 'Hello and welcome to the Flutter AI Toolkit!', + provider: GeminiProvider( + model: GenerativeModel( + model: 'gemini-1.5-flash', + apiKey: geminiApiKey, + ), + ), + ), + ); +} diff --git a/example/macos/.gitignore b/example/macos/.gitignore new file mode 100644 index 0000000..746adbb --- /dev/null +++ b/example/macos/.gitignore @@ -0,0 +1,7 @@ +# Flutter-related +**/Flutter/ephemeral/ +**/Pods/ + +# Xcode-related +**/dgph +**/xcuserdata/ diff --git a/example/macos/Flutter/Flutter-Debug.xcconfig b/example/macos/Flutter/Flutter-Debug.xcconfig new file mode 100644 index 0000000..4b81f9b --- /dev/null +++ b/example/macos/Flutter/Flutter-Debug.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/example/macos/Flutter/Flutter-Release.xcconfig b/example/macos/Flutter/Flutter-Release.xcconfig new file mode 100644 index 0000000..5caa9d1 --- /dev/null +++ b/example/macos/Flutter/Flutter-Release.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/example/macos/Flutter/GeneratedPluginRegistrant.swift b/example/macos/Flutter/GeneratedPluginRegistrant.swift new file mode 100644 index 0000000..388dbf4 --- /dev/null +++ b/example/macos/Flutter/GeneratedPluginRegistrant.swift @@ -0,0 +1,26 @@ +// +// Generated file. Do not edit. +// + +import FlutterMacOS +import Foundation + +import file_selector_macos +import firebase_app_check +import firebase_auth +import firebase_core +import path_provider_foundation +import record_darwin +import shared_preferences_foundation +import url_launcher_macos + +func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) + FLTFirebaseAppCheckPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAppCheckPlugin")) + FLTFirebaseAuthPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAuthPlugin")) + FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin")) + PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) + RecordPlugin.register(with: registry.registrar(forPlugin: "RecordPlugin")) + SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) + UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) +} diff --git a/example/macos/Podfile b/example/macos/Podfile new file mode 100644 index 0000000..b52666a --- /dev/null +++ b/example/macos/Podfile @@ -0,0 +1,43 @@ +platform :osx, '10.15' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'ephemeral', 'Flutter-Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure \"flutter pub get\" is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Flutter-Generated.xcconfig, then run \"flutter pub get\"" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_macos_podfile_setup + +target 'Runner' do + use_frameworks! + use_modular_headers! + + flutter_install_all_macos_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_macos_build_settings(target) + end +end diff --git a/example/macos/Podfile.lock b/example/macos/Podfile.lock new file mode 100644 index 0000000..7877255 --- /dev/null +++ b/example/macos/Podfile.lock @@ -0,0 +1,163 @@ +PODS: + - AppCheckCore (11.2.0): + - GoogleUtilities/Environment (~> 8.0) + - GoogleUtilities/UserDefaults (~> 8.0) + - PromisesObjC (~> 2.4) + - file_selector_macos (0.0.1): + - FlutterMacOS + - Firebase/AppCheck (11.4.2): + - Firebase/CoreOnly + - FirebaseAppCheck (~> 11.4.0) + - Firebase/Auth (11.4.2): + - Firebase/CoreOnly + - FirebaseAuth (~> 11.4.0) + - Firebase/CoreOnly (11.4.2): + - FirebaseCore (= 11.4.2) + - firebase_app_check (0.3.1-6): + - Firebase/AppCheck (~> 11.4.0) + - Firebase/CoreOnly (~> 11.4.0) + - firebase_core + - FlutterMacOS + - firebase_auth (5.3.3): + - Firebase/Auth (~> 11.4.0) + - Firebase/CoreOnly (~> 11.4.0) + - firebase_core + - FlutterMacOS + - firebase_core (3.8.0): + - Firebase/CoreOnly (~> 11.4.0) + - FlutterMacOS + - FirebaseAppCheck (11.4.0): + - AppCheckCore (~> 11.0) + - FirebaseAppCheckInterop (~> 11.0) + - FirebaseCore (~> 11.0) + - GoogleUtilities/Environment (~> 8.0) + - GoogleUtilities/UserDefaults (~> 8.0) + - FirebaseAppCheckInterop (11.5.0) + - FirebaseAuth (11.4.0): + - FirebaseAppCheckInterop (~> 11.0) + - FirebaseAuthInterop (~> 11.0) + - FirebaseCore (~> 11.4) + - FirebaseCoreExtension (~> 11.4) + - GoogleUtilities/AppDelegateSwizzler (~> 8.0) + - GoogleUtilities/Environment (~> 8.0) + - GTMSessionFetcher/Core (< 5.0, >= 3.4) + - RecaptchaInterop (~> 100.0) + - FirebaseAuthInterop (11.5.0) + - FirebaseCore (11.4.2): + - FirebaseCoreInternal (< 12.0, >= 11.4.2) + - GoogleUtilities/Environment (~> 8.0) + - GoogleUtilities/Logger (~> 8.0) + - FirebaseCoreExtension (11.4.1): + - FirebaseCore (~> 11.0) + - FirebaseCoreInternal (11.5.0): + - "GoogleUtilities/NSData+zlib (~> 8.0)" + - FlutterMacOS (1.0.0) + - GoogleUtilities/AppDelegateSwizzler (8.0.2): + - GoogleUtilities/Environment + - GoogleUtilities/Logger + - GoogleUtilities/Network + - GoogleUtilities/Privacy + - GoogleUtilities/Environment (8.0.2): + - GoogleUtilities/Privacy + - GoogleUtilities/Logger (8.0.2): + - GoogleUtilities/Environment + - GoogleUtilities/Privacy + - GoogleUtilities/Network (8.0.2): + - GoogleUtilities/Logger + - "GoogleUtilities/NSData+zlib" + - GoogleUtilities/Privacy + - GoogleUtilities/Reachability + - "GoogleUtilities/NSData+zlib (8.0.2)": + - GoogleUtilities/Privacy + - GoogleUtilities/Privacy (8.0.2) + - GoogleUtilities/Reachability (8.0.2): + - GoogleUtilities/Logger + - GoogleUtilities/Privacy + - GoogleUtilities/UserDefaults (8.0.2): + - GoogleUtilities/Logger + - GoogleUtilities/Privacy + - GTMSessionFetcher/Core (4.1.0) + - path_provider_foundation (0.0.1): + - Flutter + - FlutterMacOS + - PromisesObjC (2.4.0) + - record_darwin (1.0.0): + - FlutterMacOS + - shared_preferences_foundation (0.0.1): + - Flutter + - FlutterMacOS + - url_launcher_macos (0.0.1): + - FlutterMacOS + +DEPENDENCIES: + - file_selector_macos (from `Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos`) + - firebase_app_check (from `Flutter/ephemeral/.symlinks/plugins/firebase_app_check/macos`) + - firebase_auth (from `Flutter/ephemeral/.symlinks/plugins/firebase_auth/macos`) + - firebase_core (from `Flutter/ephemeral/.symlinks/plugins/firebase_core/macos`) + - FlutterMacOS (from `Flutter/ephemeral`) + - path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`) + - record_darwin (from `Flutter/ephemeral/.symlinks/plugins/record_darwin/macos`) + - shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`) + - url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`) + +SPEC REPOS: + trunk: + - AppCheckCore + - Firebase + - FirebaseAppCheck + - FirebaseAppCheckInterop + - FirebaseAuth + - FirebaseAuthInterop + - FirebaseCore + - FirebaseCoreExtension + - FirebaseCoreInternal + - GoogleUtilities + - GTMSessionFetcher + - PromisesObjC + +EXTERNAL SOURCES: + file_selector_macos: + :path: Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos + firebase_app_check: + :path: Flutter/ephemeral/.symlinks/plugins/firebase_app_check/macos + firebase_auth: + :path: Flutter/ephemeral/.symlinks/plugins/firebase_auth/macos + firebase_core: + :path: Flutter/ephemeral/.symlinks/plugins/firebase_core/macos + FlutterMacOS: + :path: Flutter/ephemeral + path_provider_foundation: + :path: Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin + record_darwin: + :path: Flutter/ephemeral/.symlinks/plugins/record_darwin/macos + shared_preferences_foundation: + :path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin + url_launcher_macos: + :path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos + +SPEC CHECKSUMS: + AppCheckCore: cc8fd0a3a230ddd401f326489c99990b013f0c4f + file_selector_macos: cc3858c981fe6889f364731200d6232dac1d812d + Firebase: 7fd5466678d964be78fbf536d8a3385da19c4828 + firebase_app_check: 357553dd3862f41acabcd1e88167d89c4c29c129 + firebase_auth: 85ed6c80b24da60af13391f9cdc0e566440b1963 + firebase_core: d95c4a2225d7b6ed46bc31fb2a6f421fc7c8285b + FirebaseAppCheck: 933cbda29279ed316b82360bca77602ac1af1ff2 + FirebaseAppCheckInterop: d265d9f4484e7ec1c591086408840fdd383d1213 + FirebaseAuth: c359af98bd703cbf4293eec107a40de08ede6ce6 + FirebaseAuthInterop: 1219bee9b23e6ebe84c256a0d95adab53d11c331 + FirebaseCore: 6b32c57269bd999aab34354c3923d92a6e5f3f84 + FirebaseCoreExtension: f1bc67a4702931a7caa097d8e4ac0a1b0d16720e + FirebaseCoreInternal: f47dd28ae7782e6a4738aad3106071a8fe0af604 + FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24 + GoogleUtilities: 26a3abef001b6533cf678d3eb38fd3f614b7872d + GTMSessionFetcher: 923b710231ad3d6f3f0495ac1ced35421e07d9a6 + path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46 + PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 + record_darwin: a0d515a0ef78c440c123ea3ac76184c9927a94d6 + shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78 + url_launcher_macos: c82c93949963e55b228a30115bd219499a6fe404 + +PODFILE CHECKSUM: 9ebaf0ce3d369aaa26a9ea0e159195ed94724cf3 + +COCOAPODS: 1.16.2 diff --git a/example/macos/Runner.xcodeproj/project.pbxproj b/example/macos/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..18350a0 --- /dev/null +++ b/example/macos/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,805 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXAggregateTarget section */ + 33CC111A2044C6BA0003C045 /* Flutter Assemble */ = { + isa = PBXAggregateTarget; + buildConfigurationList = 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */; + buildPhases = ( + 33CC111E2044C6BF0003C045 /* ShellScript */, + ); + dependencies = ( + ); + name = "Flutter Assemble"; + productName = FLX; + }; +/* End PBXAggregateTarget section */ + +/* Begin PBXBuildFile section */ + 0F0076023A7154B6644DD23A /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D4DF3D81367B3171410D320C /* Pods_RunnerTests.framework */; }; + 100004D3378A0047528D212B /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 834FF9386F6763BEC99CCA7A /* GoogleService-Info.plist */; }; + 12112B3F91686D50B2557BC6 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C1170A741A11CB9748472866 /* Pods_Runner.framework */; }; + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C80D7294CF71000263BE5 /* RunnerTests.swift */; }; + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; }; + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; }; + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC10EC2044A3C60003C045; + remoteInfo = Runner; + }; + 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC111A2044C6BA0003C045; + remoteInfo = FLX; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 33CC110E2044A8840003C045 /* Bundle Framework */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Bundle Framework"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 13F273E4D26A174C6CD21037 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; + 1BD10D066500AE9A3187C8B8 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; + 33CC10ED2044A3C60003C045 /* example.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = example.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; + 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; + 33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = ""; }; + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainFlutterWindow.swift; sourceTree = ""; }; + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = ""; }; + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = ""; }; + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Flutter-Generated.xcconfig"; path = "ephemeral/Flutter-Generated.xcconfig"; sourceTree = ""; }; + 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; + 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; + 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; + 6574190FAD397292529BC8CA /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; + 6B6BA039641A07C6EBC1E4C1 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; + 6CD544534B3649B776D362F9 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + 834FF9386F6763BEC99CCA7A /* GoogleService-Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; name = "GoogleService-Info.plist"; path = "Runner/GoogleService-Info.plist"; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; + C1170A741A11CB9748472866 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + D08632FA71FD25306A7A276C /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; + D4DF3D81367B3171410D320C /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 331C80D2294CF70F00263BE5 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 0F0076023A7154B6644DD23A /* Pods_RunnerTests.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EA2044A3C60003C045 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 12112B3F91686D50B2557BC6 /* Pods_Runner.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C80D6294CF71000263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C80D7294CF71000263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 33BA886A226E78AF003329D5 /* Configs */ = { + isa = PBXGroup; + children = ( + 33E5194F232828860026EE4D /* AppInfo.xcconfig */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */, + ); + path = Configs; + sourceTree = ""; + }; + 33CC10E42044A3C60003C045 = { + isa = PBXGroup; + children = ( + 33FAB671232836740065AC1E /* Runner */, + 33CEB47122A05771004F2AC0 /* Flutter */, + 331C80D6294CF71000263BE5 /* RunnerTests */, + 33CC10EE2044A3C60003C045 /* Products */, + D73912EC22F37F3D000D13A0 /* Frameworks */, + B337B3F86092E189CAF5F58B /* Pods */, + 834FF9386F6763BEC99CCA7A /* GoogleService-Info.plist */, + ); + sourceTree = ""; + }; + 33CC10EE2044A3C60003C045 /* Products */ = { + isa = PBXGroup; + children = ( + 33CC10ED2044A3C60003C045 /* example.app */, + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 33CC11242044D66E0003C045 /* Resources */ = { + isa = PBXGroup; + children = ( + 33CC10F22044A3C60003C045 /* Assets.xcassets */, + 33CC10F42044A3C60003C045 /* MainMenu.xib */, + 33CC10F72044A3C60003C045 /* Info.plist */, + ); + name = Resources; + path = ..; + sourceTree = ""; + }; + 33CEB47122A05771004F2AC0 /* Flutter */ = { + isa = PBXGroup; + children = ( + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */, + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */, + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */, + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */, + ); + path = Flutter; + sourceTree = ""; + }; + 33FAB671232836740065AC1E /* Runner */ = { + isa = PBXGroup; + children = ( + 33CC10F02044A3C60003C045 /* AppDelegate.swift */, + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */, + 33E51913231747F40026EE4D /* DebugProfile.entitlements */, + 33E51914231749380026EE4D /* Release.entitlements */, + 33CC11242044D66E0003C045 /* Resources */, + 33BA886A226E78AF003329D5 /* Configs */, + ); + path = Runner; + sourceTree = ""; + }; + B337B3F86092E189CAF5F58B /* Pods */ = { + isa = PBXGroup; + children = ( + D08632FA71FD25306A7A276C /* Pods-Runner.debug.xcconfig */, + 6B6BA039641A07C6EBC1E4C1 /* Pods-Runner.release.xcconfig */, + 6574190FAD397292529BC8CA /* Pods-Runner.profile.xcconfig */, + 6CD544534B3649B776D362F9 /* Pods-RunnerTests.debug.xcconfig */, + 13F273E4D26A174C6CD21037 /* Pods-RunnerTests.release.xcconfig */, + 1BD10D066500AE9A3187C8B8 /* Pods-RunnerTests.profile.xcconfig */, + ); + name = Pods; + path = Pods; + sourceTree = ""; + }; + D73912EC22F37F3D000D13A0 /* Frameworks */ = { + isa = PBXGroup; + children = ( + C1170A741A11CB9748472866 /* Pods_Runner.framework */, + D4DF3D81367B3171410D320C /* Pods_RunnerTests.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C80D4294CF70F00263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 53978ECA397BCDAB1914E38B /* [CP] Check Pods Manifest.lock */, + 331C80D1294CF70F00263BE5 /* Sources */, + 331C80D2294CF70F00263BE5 /* Frameworks */, + 331C80D3294CF70F00263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C80DA294CF71000263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C80D5294CF71000263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 33CC10EC2044A3C60003C045 /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 51C2F56BFDC2C924B8C53C24 /* [CP] Check Pods Manifest.lock */, + 33CC10E92044A3C60003C045 /* Sources */, + 33CC10EA2044A3C60003C045 /* Frameworks */, + 33CC10EB2044A3C60003C045 /* Resources */, + 33CC110E2044A8840003C045 /* Bundle Framework */, + 3399D490228B24CF009A79C7 /* ShellScript */, + D5034116E3D12B789A47C197 /* [CP] Embed Pods Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + 33CC11202044C79F0003C045 /* PBXTargetDependency */, + ); + name = Runner; + productName = Runner; + productReference = 33CC10ED2044A3C60003C045 /* example.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 33CC10E52044A3C60003C045 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastSwiftUpdateCheck = 0920; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C80D4294CF70F00263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 33CC10EC2044A3C60003C045; + }; + 33CC10EC2044A3C60003C045 = { + CreatedOnToolsVersion = 9.2; + LastSwiftMigration = 1100; + ProvisioningStyle = Automatic; + SystemCapabilities = { + com.apple.Sandbox = { + enabled = 1; + }; + }; + }; + 33CC111A2044C6BA0003C045 = { + CreatedOnToolsVersion = 9.2; + ProvisioningStyle = Manual; + }; + }; + }; + buildConfigurationList = 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 33CC10E42044A3C60003C045; + productRefGroup = 33CC10EE2044A3C60003C045 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 33CC10EC2044A3C60003C045 /* Runner */, + 331C80D4294CF70F00263BE5 /* RunnerTests */, + 33CC111A2044C6BA0003C045 /* Flutter Assemble */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C80D3294CF70F00263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EB2044A3C60003C045 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */, + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */, + 100004D3378A0047528D212B /* GoogleService-Info.plist in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3399D490228B24CF009A79C7 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "echo \"$PRODUCT_NAME.app\" > \"$PROJECT_DIR\"/Flutter/ephemeral/.app_filename && \"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh embed\n"; + }; + 33CC111E2044C6BF0003C045 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + Flutter/ephemeral/FlutterInputs.xcfilelist, + ); + inputPaths = ( + Flutter/ephemeral/tripwire, + ); + outputFileListPaths = ( + Flutter/ephemeral/FlutterOutputs.xcfilelist, + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; + }; + 51C2F56BFDC2C924B8C53C24 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 53978ECA397BCDAB1914E38B /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + D5034116E3D12B789A47C197 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C80D1294CF70F00263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10E92044A3C60003C045 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */, + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */, + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C80DA294CF71000263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC10EC2044A3C60003C045 /* Runner */; + targetProxy = 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */; + }; + 33CC11202044C79F0003C045 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC111A2044C6BA0003C045 /* Flutter Assemble */; + targetProxy = 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 33CC10F42044A3C60003C045 /* MainMenu.xib */ = { + isa = PBXVariantGroup; + children = ( + 33CC10F52044A3C60003C045 /* Base */, + ); + name = MainMenu.xib; + path = Runner; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 331C80DB294CF71000263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 6CD544534B3649B776D362F9 /* Pods-RunnerTests.debug.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.example.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/example.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/example"; + }; + name = Debug; + }; + 331C80DC294CF71000263BE5 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 13F273E4D26A174C6CD21037 /* Pods-RunnerTests.release.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.example.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/example.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/example"; + }; + name = Release; + }; + 331C80DD294CF71000263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 1BD10D066500AE9A3187C8B8 /* Pods-RunnerTests.profile.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.example.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/example.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/example"; + }; + name = Profile; + }; + 338D0CE9231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Profile; + }; + 338D0CEA231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Profile; + }; + 338D0CEB231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Profile; + }; + 33CC10F92044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 33CC10FA2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Release; + }; + 33CC10FC2044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 33CC10FD2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + 33CC111C2044C6BA0003C045 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Debug; + }; + 33CC111D2044C6BA0003C045 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C80DB294CF71000263BE5 /* Debug */, + 331C80DC294CF71000263BE5 /* Release */, + 331C80DD294CF71000263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10F92044A3C60003C045 /* Debug */, + 33CC10FA2044A3C60003C045 /* Release */, + 338D0CE9231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10FC2044A3C60003C045 /* Debug */, + 33CC10FD2044A3C60003C045 /* Release */, + 338D0CEA231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC111C2044C6BA0003C045 /* Debug */, + 33CC111D2044C6BA0003C045 /* Release */, + 338D0CEB231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 33CC10E52044A3C60003C045 /* Project object */; +} diff --git a/example/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/example/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/example/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..15368ec --- /dev/null +++ b/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/example/macos/Runner.xcworkspace/contents.xcworkspacedata b/example/macos/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..21a3cc1 --- /dev/null +++ b/example/macos/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/example/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/example/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/example/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/example/macos/Runner/AppDelegate.swift b/example/macos/Runner/AppDelegate.swift new file mode 100644 index 0000000..b3c1761 --- /dev/null +++ b/example/macos/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import Cocoa +import FlutterMacOS + +@main +class AppDelegate: FlutterAppDelegate { + override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + return true + } + + override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { + return true + } +} diff --git a/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..a2ec33f --- /dev/null +++ b/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,68 @@ +{ + "images" : [ + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_16.png", + "scale" : "1x" + }, + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "2x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "1x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_64.png", + "scale" : "2x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_128.png", + "scale" : "1x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "2x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "1x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "2x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "1x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_1024.png", + "scale" : "2x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png new file mode 100644 index 0000000..82b6f9d Binary files /dev/null and b/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png differ diff --git a/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png new file mode 100644 index 0000000..13b35eb Binary files /dev/null and b/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png differ diff --git a/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png b/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png new file mode 100644 index 0000000..0a3f5fa Binary files /dev/null and b/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png differ diff --git a/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png b/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png new file mode 100644 index 0000000..bdb5722 Binary files /dev/null and b/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png differ diff --git a/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png b/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png new file mode 100644 index 0000000..f083318 Binary files /dev/null and b/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png differ diff --git a/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png b/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png new file mode 100644 index 0000000..326c0e7 Binary files /dev/null and b/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png differ diff --git a/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png b/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png new file mode 100644 index 0000000..2f1632c Binary files /dev/null and b/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png differ diff --git a/example/macos/Runner/Base.lproj/MainMenu.xib b/example/macos/Runner/Base.lproj/MainMenu.xib new file mode 100644 index 0000000..80e867a --- /dev/null +++ b/example/macos/Runner/Base.lproj/MainMenu.xib @@ -0,0 +1,343 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/example/macos/Runner/Configs/AppInfo.xcconfig b/example/macos/Runner/Configs/AppInfo.xcconfig new file mode 100644 index 0000000..92fb3cd --- /dev/null +++ b/example/macos/Runner/Configs/AppInfo.xcconfig @@ -0,0 +1,14 @@ +// Application-level settings for the Runner target. +// +// This may be replaced with something auto-generated from metadata (e.g., pubspec.yaml) in the +// future. If not, the values below would default to using the project name when this becomes a +// 'flutter create' template. + +// The application's name. By default this is also the title of the Flutter window. +PRODUCT_NAME = example + +// The application's bundle identifier +PRODUCT_BUNDLE_IDENTIFIER = com.example.example + +// The copyright displayed in application information +PRODUCT_COPYRIGHT = Copyright © 2024 com.example. All rights reserved. diff --git a/example/macos/Runner/Configs/Debug.xcconfig b/example/macos/Runner/Configs/Debug.xcconfig new file mode 100644 index 0000000..36b0fd9 --- /dev/null +++ b/example/macos/Runner/Configs/Debug.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Debug.xcconfig" +#include "Warnings.xcconfig" diff --git a/example/macos/Runner/Configs/Release.xcconfig b/example/macos/Runner/Configs/Release.xcconfig new file mode 100644 index 0000000..dff4f49 --- /dev/null +++ b/example/macos/Runner/Configs/Release.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Release.xcconfig" +#include "Warnings.xcconfig" diff --git a/example/macos/Runner/Configs/Warnings.xcconfig b/example/macos/Runner/Configs/Warnings.xcconfig new file mode 100644 index 0000000..42bcbf4 --- /dev/null +++ b/example/macos/Runner/Configs/Warnings.xcconfig @@ -0,0 +1,13 @@ +WARNING_CFLAGS = -Wall -Wconditional-uninitialized -Wnullable-to-nonnull-conversion -Wmissing-method-return-type -Woverlength-strings +GCC_WARN_UNDECLARED_SELECTOR = YES +CLANG_UNDEFINED_BEHAVIOR_SANITIZER_NULLABILITY = YES +CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE +CLANG_WARN__DUPLICATE_METHOD_MATCH = YES +CLANG_WARN_PRAGMA_PACK = YES +CLANG_WARN_STRICT_PROTOTYPES = YES +CLANG_WARN_COMMA = YES +GCC_WARN_STRICT_SELECTOR_MATCH = YES +CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = YES +CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES +GCC_WARN_SHADOW = YES +CLANG_WARN_UNREACHABLE_CODE = YES diff --git a/example/macos/Runner/DebugProfile.entitlements b/example/macos/Runner/DebugProfile.entitlements new file mode 100644 index 0000000..5a2ec64 --- /dev/null +++ b/example/macos/Runner/DebugProfile.entitlements @@ -0,0 +1,18 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.cs.allow-jit + + com.apple.security.network.server + + com.apple.security.network.client + + com.apple.security.files.user-selected.read-only + + com.apple.security.device.audio-input + + + diff --git a/example/macos/Runner/Info.plist b/example/macos/Runner/Info.plist new file mode 100644 index 0000000..3895a14 --- /dev/null +++ b/example/macos/Runner/Info.plist @@ -0,0 +1,34 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIconFile + + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + NSHumanReadableCopyright + $(PRODUCT_COPYRIGHT) + NSMainNibFile + MainMenu + NSPrincipalClass + NSApplication + NSMicrophoneUsageDescription + $(PRODUCT_NAME) would like to access your microphone. + + diff --git a/example/macos/Runner/MainFlutterWindow.swift b/example/macos/Runner/MainFlutterWindow.swift new file mode 100644 index 0000000..3cc05eb --- /dev/null +++ b/example/macos/Runner/MainFlutterWindow.swift @@ -0,0 +1,15 @@ +import Cocoa +import FlutterMacOS + +class MainFlutterWindow: NSWindow { + override func awakeFromNib() { + let flutterViewController = FlutterViewController() + let windowFrame = self.frame + self.contentViewController = flutterViewController + self.setFrame(windowFrame, display: true) + + RegisterGeneratedPlugins(registry: flutterViewController) + + super.awakeFromNib() + } +} diff --git a/example/macos/Runner/Release.entitlements b/example/macos/Runner/Release.entitlements new file mode 100644 index 0000000..4811e83 --- /dev/null +++ b/example/macos/Runner/Release.entitlements @@ -0,0 +1,14 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.network.client + + com.apple.security.files.user-selected.read-only + + com.apple.security.device.audio-input + + + diff --git a/example/macos/RunnerTests/RunnerTests.swift b/example/macos/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..61f3bd1 --- /dev/null +++ b/example/macos/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Cocoa +import FlutterMacOS +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/example/pubspec.lock b/example/pubspec.lock new file mode 100644 index 0000000..d05d040 --- /dev/null +++ b/example/pubspec.lock @@ -0,0 +1,969 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + _flutterfire_internals: + dependency: transitive + description: + name: _flutterfire_internals + sha256: "71c01c1998c40b3af1944ad0a5f374b4e6fef7f3d2df487f3970dbeadaeb25a1" + url: "https://pub.dev" + source: hosted + version: "1.3.46" + args: + dependency: transitive + description: + name: args + sha256: bf9f5caeea8d8fe6721a9c358dd8a5c1947b27f1cfaa18b39c301273594919e6 + url: "https://pub.dev" + source: hosted + version: "2.6.0" + async: + dependency: transitive + description: + name: async + sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c" + url: "https://pub.dev" + source: hosted + version: "2.11.0" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + camera: + dependency: transitive + description: + name: camera + sha256: "26ff41045772153f222ffffecba711a206f670f5834d40ebf5eed3811692f167" + url: "https://pub.dev" + source: hosted + version: "0.11.0+2" + camera_android_camerax: + dependency: transitive + description: + name: camera_android_camerax + sha256: e3627fdc2132d89212b8a8676679f5b07008c7e3d8ae00cea775c3397f9e742b + url: "https://pub.dev" + source: hosted + version: "0.6.10" + camera_avfoundation: + dependency: transitive + description: + name: camera_avfoundation + sha256: "2e4c568f70e406ccb87376bc06b53d2f5bebaab71e2fbcc1a950e31449381bcf" + url: "https://pub.dev" + source: hosted + version: "0.9.17+5" + camera_platform_interface: + dependency: transitive + description: + name: camera_platform_interface + sha256: b3ede1f171532e0d83111fe0980b46d17f1aa9788a07a2fbed07366bbdbb9061 + url: "https://pub.dev" + source: hosted + version: "2.8.0" + camera_web: + dependency: transitive + description: + name: camera_web + sha256: "595f28c89d1fb62d77c73c633193755b781c6d2e0ebcd8dc25b763b514e6ba8f" + url: "https://pub.dev" + source: hosted + version: "0.3.5" + characters: + dependency: transitive + description: + name: characters + sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605" + url: "https://pub.dev" + source: hosted + version: "1.3.0" + clock: + dependency: transitive + description: + name: clock + sha256: cb6d7f03e1de671e34607e909a7213e31d7752be4fb66a86d29fe1eb14bfb5cf + url: "https://pub.dev" + source: hosted + version: "1.1.1" + collection: + dependency: transitive + description: + name: collection + sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a + url: "https://pub.dev" + source: hosted + version: "1.18.0" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "7caf6a750a0c04effbb52a676dce9a4a592e10ad35c34d6d2d0e4811160d5670" + url: "https://pub.dev" + source: hosted + version: "0.3.4+2" + crypto: + dependency: transitive + description: + name: crypto + sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855" + url: "https://pub.dev" + source: hosted + version: "3.0.6" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 + url: "https://pub.dev" + source: hosted + version: "1.0.8" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "511392330127add0b769b75a987850d136345d9227c6b94c96a04cf4a391bf78" + url: "https://pub.dev" + source: hosted + version: "1.3.1" + ffi: + dependency: transitive + description: + name: ffi + sha256: "16ed7b077ef01ad6170a3d0c57caa4a112a38d7a2ed5602e0aca9ca6f3d98da6" + url: "https://pub.dev" + source: hosted + version: "2.1.3" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + file_selector: + dependency: transitive + description: + name: file_selector + sha256: "5019692b593455127794d5718304ff1ae15447dea286cdda9f0db2a796a1b828" + url: "https://pub.dev" + source: hosted + version: "1.0.3" + file_selector_android: + dependency: transitive + description: + name: file_selector_android + sha256: "934850f9702b0f9031bc331a306e7bebc62f894a6e5ca6c0681c7af17e7afb50" + url: "https://pub.dev" + source: hosted + version: "0.5.1+11" + file_selector_ios: + dependency: transitive + description: + name: file_selector_ios + sha256: "94b98ad950b8d40d96fee8fa88640c2e4bd8afcdd4817993bd04e20310f45420" + url: "https://pub.dev" + source: hosted + version: "0.5.3+1" + file_selector_linux: + dependency: transitive + description: + name: file_selector_linux + sha256: "54cbbd957e1156d29548c7d9b9ec0c0ebb6de0a90452198683a7d23aed617a33" + url: "https://pub.dev" + source: hosted + version: "0.9.3+2" + file_selector_macos: + dependency: transitive + description: + name: file_selector_macos + sha256: "271ab9986df0c135d45c3cdb6bd0faa5db6f4976d3e4b437cf7d0f258d941bfc" + url: "https://pub.dev" + source: hosted + version: "0.9.4+2" + file_selector_platform_interface: + dependency: transitive + description: + name: file_selector_platform_interface + sha256: a3994c26f10378a039faa11de174d7b78eb8f79e4dd0af2a451410c1a5c3f66b + url: "https://pub.dev" + source: hosted + version: "2.6.2" + file_selector_web: + dependency: transitive + description: + name: file_selector_web + sha256: c4c0ea4224d97a60a7067eca0c8fd419e708ff830e0c83b11a48faf566cec3e7 + url: "https://pub.dev" + source: hosted + version: "0.9.4+2" + file_selector_windows: + dependency: transitive + description: + name: file_selector_windows + sha256: "8f5d2f6590d51ecd9179ba39c64f722edc15226cc93dcc8698466ad36a4a85a4" + url: "https://pub.dev" + source: hosted + version: "0.9.3+3" + firebase_app_check: + dependency: transitive + description: + name: firebase_app_check + sha256: "0b7e85c11fbdaada02d01f5cc5b5feffda4983d229806c30cadcc013c2fa3c1f" + url: "https://pub.dev" + source: hosted + version: "0.3.1+6" + firebase_app_check_platform_interface: + dependency: transitive + description: + name: firebase_app_check_platform_interface + sha256: "21ba031a28e62e12eddf8c39852607c3d04673fdfd75c3ed7f913e5802eb7d8e" + url: "https://pub.dev" + source: hosted + version: "0.1.0+40" + firebase_app_check_web: + dependency: transitive + description: + name: firebase_app_check_web + sha256: "36de135e9e0cc1554d3e3f73b437d3ff6be7132f2ef0a9999d0c6e414fa3d04b" + url: "https://pub.dev" + source: hosted + version: "0.2.0+2" + firebase_auth: + dependency: transitive + description: + name: firebase_auth + sha256: "49c356bac95ed234805e3bb928a86d5b21a4d3745d77be53ecf2d61409ddb802" + url: "https://pub.dev" + source: hosted + version: "5.3.3" + firebase_auth_platform_interface: + dependency: transitive + description: + name: firebase_auth_platform_interface + sha256: "9bc336ce673ea90a9dbdb04f0e9a3e52a32321898dc869cdefe6cc0f0db369ed" + url: "https://pub.dev" + source: hosted + version: "7.4.9" + firebase_auth_web: + dependency: transitive + description: + name: firebase_auth_web + sha256: "56dcce4293e2a2c648c33ab72c09e888bd0e64cbb1681a32575ec9dc9c2f67f3" + url: "https://pub.dev" + source: hosted + version: "5.13.4" + firebase_core: + dependency: "direct main" + description: + name: firebase_core + sha256: "2438a75ad803e818ad3bd5df49137ee619c46b6fc7101f4dbc23da07305ce553" + url: "https://pub.dev" + source: hosted + version: "3.8.0" + firebase_core_platform_interface: + dependency: transitive + description: + name: firebase_core_platform_interface + sha256: e30da58198a6d4b49d5bce4e852f985c32cb10db329ebef9473db2b9f09ce810 + url: "https://pub.dev" + source: hosted + version: "5.3.0" + firebase_core_web: + dependency: transitive + description: + name: firebase_core_web + sha256: f967a7138f5d2ffb1ce15950e2a382924239eaa521150a8f144af34e68b3b3e5 + url: "https://pub.dev" + source: hosted + version: "2.18.1" + firebase_vertexai: + dependency: "direct main" + description: + name: firebase_vertexai + sha256: d86d376fb7e4d4873a4d1830eef72531291a2092e087a69fabd278cb0e967a04 + url: "https://pub.dev" + source: hosted + version: "1.0.3" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_ai_toolkit: + dependency: "direct main" + description: + path: ".." + relative: true + source: path + version: "0.6.5" + flutter_context_menu: + dependency: transitive + description: + name: flutter_context_menu + sha256: "4bc1dc30ae5aa705ed99ebbeb875898c6341a6d092397a566fecd5184b392380" + url: "https://pub.dev" + source: hosted + version: "0.2.0" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "5398f14efa795ffb7a33e9b6a08798b26a180edac4ad7db3f231e40f82ce11e1" + url: "https://pub.dev" + source: hosted + version: "5.0.0" + flutter_markdown: + dependency: "direct main" + description: + name: flutter_markdown + sha256: "255b00afa1a7bad19727da6a7780cf3db6c3c12e68d302d85e0ff1fdf173db9e" + url: "https://pub.dev" + source: hosted + version: "0.7.4+3" + flutter_picture_taker: + dependency: transitive + description: + name: flutter_picture_taker + sha256: d24d4c10e42324832b550bd59d1fe84129e860b75b4b2d57d6b398a41fd5dc9a + url: "https://pub.dev" + source: hosted + version: "0.2.0" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: "9b78450b89f059e96c9ebb355fa6b3df1d6b330436e0b885fb49594c41721398" + url: "https://pub.dev" + source: hosted + version: "2.0.23" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + future_builder_ex: + dependency: "direct main" + description: + name: future_builder_ex + sha256: a6f1cbccd1ef39a842febf2d09b71da2c5d5f7aa75c0c798646d14c42100e244 + url: "https://pub.dev" + source: hosted + version: "4.0.0" + gap: + dependency: "direct main" + description: + name: gap + sha256: f19387d4e32f849394758b91377f9153a1b41d79513ef7668c088c77dbc6955d + url: "https://pub.dev" + source: hosted + version: "3.0.1" + go_router: + dependency: "direct main" + description: + name: go_router + sha256: "8660b74171fafae4aa8202100fa2e55349e078281dadc73a241eb8e758534d9d" + url: "https://pub.dev" + source: hosted + version: "14.6.1" + google_fonts: + dependency: "direct main" + description: + name: google_fonts + sha256: b1ac0fe2832c9cc95e5e88b57d627c5e68c223b9657f4b96e1487aa9098c7b82 + url: "https://pub.dev" + source: hosted + version: "6.2.1" + google_generative_ai: + dependency: "direct main" + description: + name: google_generative_ai + sha256: "81dae159c89e4d9bdc46955b6f4ee5ae0a291f9e8f990d76f43944e0d6041d4f" + url: "https://pub.dev" + source: hosted + version: "0.4.6" + http: + dependency: transitive + description: + name: http + sha256: b9c29a161230ee03d3ccf545097fccd9b87a5264228c5d348202e0f0c28f9010 + url: "https://pub.dev" + source: hosted + version: "1.2.2" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b" + url: "https://pub.dev" + source: hosted + version: "4.0.2" + image_picker: + dependency: transitive + description: + name: image_picker + sha256: "021834d9c0c3de46bf0fe40341fa07168407f694d9b2bb18d532dc1261867f7a" + url: "https://pub.dev" + source: hosted + version: "1.1.2" + image_picker_android: + dependency: transitive + description: + name: image_picker_android + sha256: "8faba09ba361d4b246dc0a17cb4289b3324c2b9f6db7b3d457ee69106a86bd32" + url: "https://pub.dev" + source: hosted + version: "0.8.12+17" + image_picker_for_web: + dependency: transitive + description: + name: image_picker_for_web + sha256: "717eb042ab08c40767684327be06a5d8dbb341fe791d514e4b92c7bbe1b7bb83" + url: "https://pub.dev" + source: hosted + version: "3.0.6" + image_picker_ios: + dependency: transitive + description: + name: image_picker_ios + sha256: "4f0568120c6fcc0aaa04511cb9f9f4d29fc3d0139884b1d06be88dcec7641d6b" + url: "https://pub.dev" + source: hosted + version: "0.8.12+1" + image_picker_linux: + dependency: transitive + description: + name: image_picker_linux + sha256: "4ed1d9bb36f7cd60aa6e6cd479779cc56a4cb4e4de8f49d487b1aaad831300fa" + url: "https://pub.dev" + source: hosted + version: "0.2.1+1" + image_picker_macos: + dependency: transitive + description: + name: image_picker_macos + sha256: "3f5ad1e8112a9a6111c46d0b57a7be2286a9a07fc6e1976fdf5be2bd31d4ff62" + url: "https://pub.dev" + source: hosted + version: "0.2.1+1" + image_picker_platform_interface: + dependency: transitive + description: + name: image_picker_platform_interface + sha256: "9ec26d410ff46f483c5519c29c02ef0e02e13a543f882b152d4bfd2f06802f80" + url: "https://pub.dev" + source: hosted + version: "2.10.0" + image_picker_windows: + dependency: transitive + description: + name: image_picker_windows + sha256: "6ad07afc4eb1bc25f3a01084d28520496c4a3bb0cb13685435838167c9dcedeb" + url: "https://pub.dev" + source: hosted + version: "0.2.1+1" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "3f87a60e8c63aecc975dda1ceedbc8f24de75f09e4856ea27daf8958f2f0ce05" + url: "https://pub.dev" + source: hosted + version: "10.0.5" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "932549fb305594d82d7183ecd9fa93463e9914e1b67cacc34bc40906594a1806" + url: "https://pub.dev" + source: hosted + version: "3.0.5" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" + url: "https://pub.dev" + source: hosted + version: "3.0.1" + lints: + dependency: transitive + description: + name: lints + sha256: "3315600f3fb3b135be672bf4a178c55f274bebe368325ae18462c89ac1e3b413" + url: "https://pub.dev" + source: hosted + version: "5.0.0" + logger: + dependency: transitive + description: + name: logger + sha256: be4b23575aac7ebf01f225a241eb7f6b5641eeaf43c6a8613510fc2f8cf187d1 + url: "https://pub.dev" + source: hosted + version: "2.5.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + markdown: + dependency: transitive + description: + name: markdown + sha256: ef2a1298144e3f985cc736b22e0ccdaf188b5b3970648f2d9dc13efd1d9df051 + url: "https://pub.dev" + source: hosted + version: "7.2.2" + matcher: + dependency: transitive + description: + name: matcher + sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb + url: "https://pub.dev" + source: hosted + version: "0.12.16+1" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + url: "https://pub.dev" + source: hosted + version: "0.11.1" + meta: + dependency: transitive + description: + name: meta + sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7 + url: "https://pub.dev" + source: hosted + version: "1.15.0" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + path: + dependency: "direct main" + description: + name: path + sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af" + url: "https://pub.dev" + source: hosted + version: "1.9.0" + path_provider: + dependency: "direct main" + description: + name: path_provider + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + url: "https://pub.dev" + source: hosted + version: "2.1.5" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "8c4967f8b7cb46dc914e178daa29813d83ae502e0529d7b0478330616a691ef7" + url: "https://pub.dev" + source: hosted + version: "2.2.14" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: f234384a3fdd67f989b4d54a5d73ca2a6c422fa55ae694381ae0f4375cd1ea16 + url: "https://pub.dev" + source: hosted + version: "2.4.0" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.dev" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + record: + dependency: transitive + description: + name: record + sha256: "8cb57763d954624fbc673874930c6f1ceca3baaf9bfee24b25da6fd451362394" + url: "https://pub.dev" + source: hosted + version: "5.2.0" + record_android: + dependency: transitive + description: + name: record_android + sha256: "0b4739a2502fff402b0ac0ff1d6b2740854d116d78e06a4a16b3989821f84446" + url: "https://pub.dev" + source: hosted + version: "1.3.0" + record_darwin: + dependency: transitive + description: + name: record_darwin + sha256: e487eccb19d82a9a39cd0126945cfc47b9986e0df211734e2788c95e3f63c82c + url: "https://pub.dev" + source: hosted + version: "1.2.2" + record_linux: + dependency: transitive + description: + name: record_linux + sha256: "74d41a9ebb1eb498a38e9a813dd524e8f0b4fdd627270bda9756f437b110a3e3" + url: "https://pub.dev" + source: hosted + version: "0.7.2" + record_platform_interface: + dependency: transitive + description: + name: record_platform_interface + sha256: "8a575828733d4c3cb5983c914696f40db8667eab3538d4c41c50cbb79e722ef4" + url: "https://pub.dev" + source: hosted + version: "1.2.0" + record_web: + dependency: transitive + description: + name: record_web + sha256: "10cb041349024ce4256e11dd35874df26d8b45b800678f2f51fd1318901adc64" + url: "https://pub.dev" + source: hosted + version: "1.1.4" + record_windows: + dependency: transitive + description: + name: record_windows + sha256: "7bce0ac47454212ca8bfa72791d8b6a951f2fb0d4b953b64443c014227f035b4" + url: "https://pub.dev" + source: hosted + version: "1.0.4" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: "95f9997ca1fb9799d494d0cb2a780fd7be075818d59f00c43832ed112b158a82" + url: "https://pub.dev" + source: hosted + version: "2.3.3" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: "3b9febd815c9ca29c9e3520d50ec32f49157711e143b7a4ca039eb87e8ade5ab" + url: "https://pub.dev" + source: hosted + version: "2.3.3" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "07e050c7cd39bad516f8d64c455f04508d09df104be326d8c02551590a0d513d" + url: "https://pub.dev" + source: hosted + version: "2.5.3" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: d2ca4132d3946fec2184261726b355836a82c33d7d5b67af32692aff18a4684e + url: "https://pub.dev" + source: hosted + version: "2.4.2" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.99" + source_span: + dependency: transitive + description: + name: source_span + sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c" + url: "https://pub.dev" + source: hosted + version: "1.10.0" + split_view: + dependency: "direct main" + description: + name: split_view + sha256: "7ad0e1c40703901aa1175fd465dec5e965b55324f9cc8e51526479a4a96d01a4" + url: "https://pub.dev" + source: hosted + version: "3.2.1" + sprintf: + dependency: transitive + description: + name: sprintf + sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23" + url: "https://pub.dev" + source: hosted + version: "7.0.0" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "73713990125a6d93122541237550ee3352a2d84baad52d375a4cad2eb9b7ce0b" + url: "https://pub.dev" + source: hosted + version: "1.11.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7 + url: "https://pub.dev" + source: hosted + version: "2.1.2" + stream_transform: + dependency: transitive + description: + name: stream_transform + sha256: "14a00e794c7c11aa145a170587321aedce29769c08d7f58b1d141da75e3b1c6f" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "556692adab6cfa87322a115640c11f13cb77b3f076ddcc5d6ae3c20242bedcde" + url: "https://pub.dev" + source: hosted + version: "1.2.0" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + test_api: + dependency: transitive + description: + name: test_api + sha256: "5b8a98dafc4d5c4c9c72d8b31ab2b23fc13422348d2997120294d3bac86b4ddb" + url: "https://pub.dev" + source: hosted + version: "0.7.2" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + universal_platform: + dependency: transitive + description: + name: universal_platform + sha256: "64e16458a0ea9b99260ceb5467a214c1f298d647c659af1bff6d3bf82536b1ec" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + url_launcher: + dependency: "direct main" + description: + name: url_launcher + sha256: "9d06212b1362abc2f0f0d78e6f09f726608c74e3b9462e8368bb03314aa8d603" + url: "https://pub.dev" + source: hosted + version: "6.3.1" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: "6fc2f56536ee873eeb867ad176ae15f304ccccc357848b351f6f0d8d4a40d193" + url: "https://pub.dev" + source: hosted + version: "6.3.14" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: e43b677296fadce447e987a2f519dcf5f6d1e527dc35d01ffab4fff5b8a7063e + url: "https://pub.dev" + source: hosted + version: "6.3.1" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: "4e9ba368772369e3e08f231d2301b4ef72b9ff87c31192ef471b380ef29a4935" + url: "https://pub.dev" + source: hosted + version: "3.2.1" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "769549c999acdb42b8bcfa7c43d72bf79a382ca7441ab18a808e101149daf672" + url: "https://pub.dev" + source: hosted + version: "3.2.1" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: "772638d3b34c779ede05ba3d38af34657a05ac55b06279ea6edd409e323dca8e" + url: "https://pub.dev" + source: hosted + version: "2.3.3" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "44cf3aabcedde30f2dba119a9dea3b0f2672fbe6fa96e85536251d678216b3c4" + url: "https://pub.dev" + source: hosted + version: "3.1.3" + uuid: + dependency: "direct main" + description: + name: uuid + sha256: a5be9ef6618a7ac1e964353ef476418026db906c4facdedaa299b7a2e71690ff + url: "https://pub.dev" + source: hosted + version: "4.5.1" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "5c5f338a667b4c644744b661f309fb8080bb94b18a7e91ef1dbd343bed00ed6d" + url: "https://pub.dev" + source: hosted + version: "14.2.5" + waveform_flutter: + dependency: transitive + description: + name: waveform_flutter + sha256: "3b4362b47295930cc71ef147985fddfd28673a939b7996e1718fa7433f3bc7a9" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + waveform_recorder: + dependency: transitive + description: + name: waveform_recorder + sha256: db76db7c36a12a10d601b96742f83f4066ca1e0add4e05bcd185f49ec00f6dee + url: "https://pub.dev" + source: hosted + version: "1.3.1" + web: + dependency: transitive + description: + name: web + sha256: cd3543bd5798f6ad290ea73d210f423502e71900302dde696f8bff84bf89a1cb + url: "https://pub.dev" + source: hosted + version: "1.1.0" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" +sdks: + dart: ">=3.5.4 <4.0.0" + flutter: ">=3.24.0" diff --git a/example/pubspec.yaml b/example/pubspec.yaml new file mode 100644 index 0000000..eedb85c --- /dev/null +++ b/example/pubspec.yaml @@ -0,0 +1,39 @@ +name: flutter_ai_toolkit_example +description: "Sample apps showing off various features of the Flutter AI Toolkit." +publish_to: 'none' +version: 0.6.5 + +environment: + sdk: ^3.4.4 + +dependencies: + flutter: + sdk: flutter + flutter_ai_toolkit: + path: .. + cupertino_icons: ^1.0.8 + google_generative_ai: ^0.4.3 + firebase_core: ^3.4.0 + firebase_vertexai: ^1.0.1 + shared_preferences: ^2.3.2 + url_launcher: ^6.3.0 + gap: ^3.0.1 + go_router: ^14.2.8 + uuid: ^4.5.1 + path: ^1.9.0 + path_provider: ^2.1.4 + flutter_markdown: ^0.7.4+1 + google_fonts: ^6.2.1 + future_builder_ex: ^4.0.0 + split_view: ^3.2.1 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^5.0.0 + +flutter: + uses-material-design: true + assets: + - assets/recipes_default.json + - assets/halloween-bg.png diff --git a/example/web/favicon.png b/example/web/favicon.png new file mode 100644 index 0000000..8aaa46a Binary files /dev/null and b/example/web/favicon.png differ diff --git a/example/web/icons/Icon-192.png b/example/web/icons/Icon-192.png new file mode 100644 index 0000000..b749bfe Binary files /dev/null and b/example/web/icons/Icon-192.png differ diff --git a/example/web/icons/Icon-512.png b/example/web/icons/Icon-512.png new file mode 100644 index 0000000..88cfd48 Binary files /dev/null and b/example/web/icons/Icon-512.png differ diff --git a/example/web/icons/Icon-maskable-192.png b/example/web/icons/Icon-maskable-192.png new file mode 100644 index 0000000..eb9b4d7 Binary files /dev/null and b/example/web/icons/Icon-maskable-192.png differ diff --git a/example/web/icons/Icon-maskable-512.png b/example/web/icons/Icon-maskable-512.png new file mode 100644 index 0000000..d69c566 Binary files /dev/null and b/example/web/icons/Icon-maskable-512.png differ diff --git a/example/web/index.html b/example/web/index.html new file mode 100644 index 0000000..1aa025d --- /dev/null +++ b/example/web/index.html @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + example + + + + + + diff --git a/example/web/manifest.json b/example/web/manifest.json new file mode 100644 index 0000000..096edf8 --- /dev/null +++ b/example/web/manifest.json @@ -0,0 +1,35 @@ +{ + "name": "example", + "short_name": "example", + "start_url": ".", + "display": "standalone", + "background_color": "#0175C2", + "theme_color": "#0175C2", + "description": "A new Flutter project.", + "orientation": "portrait-primary", + "prefer_related_applications": false, + "icons": [ + { + "src": "icons/Icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icons/Icon-512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "icons/Icon-maskable-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "icons/Icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} diff --git a/font_svg/README.md b/font_svg/README.md new file mode 100644 index 0000000..b903f5c --- /dev/null +++ b/font_svg/README.md @@ -0,0 +1,25 @@ +`lib/fonts/FatIcons.ttf` is generated at [fluttericon.com](https://www.fluttericon.com/) from the following files: +- `font_svg/spark-icon.svg` +- `font_svg/submit-icon.svg` + +SVG sources: +- `font_svg/submit-icon.svg` was generated by flipping the svg in `reply_24dp_5F6368_FILL0_wght400_GRAD0_opsz24.svg` horizontally + +- `font_svg/reply_24dp_5F6368_FILL0_wght400_GRAD0_opsz24.svg` was generated by exporting the SVG from [the SVG of Google Symbols "reply" character](https://fonts.google.com/icons?icon.query=reply) + +- `font_svg/spark-icon.svg` was purchased at [the Noun Project](https://thenounproject.com/icon/spark-6645136/) + +Material icons: +The following icons will pulled in from Material and packaged into the FatIcons.ttf so that Material itself wasn't required for a Cupertino app: +- Icons.add +- Icons.attach_file +- Icons.stop +- Icons.mic +- Icons.close +- Icons.camera_alt +- Icons.image +- Icons.edit +- Icons.copy + +more info: +https://stackoverflow.com/a/75657218 diff --git a/font_svg/reply_24dp_5F6368_FILL0_wght400_GRAD0_opsz24.svg b/font_svg/reply_24dp_5F6368_FILL0_wght400_GRAD0_opsz24.svg new file mode 100644 index 0000000..9e29a55 --- /dev/null +++ b/font_svg/reply_24dp_5F6368_FILL0_wght400_GRAD0_opsz24.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/font_svg/spark-icon.svg b/font_svg/spark-icon.svg new file mode 100644 index 0000000..1c6f763 --- /dev/null +++ b/font_svg/spark-icon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/font_svg/submit-icon.svg b/font_svg/submit-icon.svg new file mode 100644 index 0000000..4a95b6a --- /dev/null +++ b/font_svg/submit-icon.svg @@ -0,0 +1,9 @@ + +Created with Fabric.js 3.5.0 + + + + + + + \ No newline at end of file diff --git a/lib/flutter_ai_toolkit.dart b/lib/flutter_ai_toolkit.dart new file mode 100644 index 0000000..eb0141c --- /dev/null +++ b/lib/flutter_ai_toolkit.dart @@ -0,0 +1,20 @@ +// Copyright 2024 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +/// A library for integrating AI-powered chat functionality into Flutter +/// applications. +/// +/// This library provides a set of tools and widgets to easily incorporate AI +/// language models into your Flutter app, enabling interactive chat experiences +/// with various AI providers. +/// +/// Key components: +/// - LLM providers: Interfaces and implementations for different AI services. +/// - Chat UI: Ready-to-use widgets for displaying chat interfaces. +library; + +export 'src/providers/interface/chat_message.dart'; +export 'src/providers/providers.dart'; +export 'src/styles/styles.dart'; +export 'src/views/llm_chat_view/llm_chat_view.dart'; diff --git a/lib/fonts/FatIcons.ttf b/lib/fonts/FatIcons.ttf new file mode 100644 index 0000000..7411934 Binary files /dev/null and b/lib/fonts/FatIcons.ttf differ diff --git a/lib/src/chat_view_model/chat_view_model.dart b/lib/src/chat_view_model/chat_view_model.dart new file mode 100644 index 0000000..b908554 --- /dev/null +++ b/lib/src/chat_view_model/chat_view_model.dart @@ -0,0 +1,86 @@ +// Copyright 2024 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/foundation.dart'; + +import '../providers/interface/llm_provider.dart'; +import '../styles/llm_chat_view_style.dart'; +import '../views/response_builder.dart'; + +@immutable + +/// A view model class for managing chat interactions and configurations. +/// +/// This class encapsulates the core data and functionality needed for the chat +/// interface, including the LLM provider, style configuration, welcome message, +/// response builder, and message sender. +class ChatViewModel { + /// Creates a new [ChatViewModel] instance. + /// + /// [provider] is the required [LlmProvider] for handling LLM interactions. + /// [style] is the optional [LlmChatViewStyle] for customizing the chat view's + /// appearance. [welcomeMessage] is an optional message displayed when the + /// chat interface is first opened. [responseBuilder] is an optional builder + /// for customizing chat responses. [messageSender] is an optional + /// [LlmStreamGenerator] for sending messages. + const ChatViewModel({ + required this.provider, + required this.style, + required this.welcomeMessage, + required this.responseBuilder, + required this.messageSender, + }); + + /// The LLM provider for the chat interface. + /// + /// This provider is responsible for managing interactions with the language + /// model, including sending and receiving messages. + final LlmProvider provider; + + /// The style configuration for the chat view. + /// + /// Defines visual properties like colors, decorations, and layout parameters + /// for the chat interface. If null, default styling will be applied. + final LlmChatViewStyle? style; + + /// The welcome message to display in the chat interface. + /// + /// This message is shown to users when they first open the chat interface, + /// providing a friendly introduction or prompt. + final String? welcomeMessage; + + /// The builder for the chat response. + /// + /// This builder allows for customization of how chat responses are rendered + /// in the interface, enabling tailored presentation of messages. + final ResponseBuilder? responseBuilder; + + /// The message sender for the chat interface. + /// + /// This optional generator is used to send messages to the LLM, allowing for + /// asynchronous communication and response handling. + final LlmStreamGenerator? messageSender; + + // The following is needed to support the + // ChatViewModelProvider.updateShouldNotify implementation + @override + bool operator ==(Object other) => + identical(this, other) || + (other is ChatViewModel && + other.provider == provider && + other.style == style && + other.welcomeMessage == welcomeMessage && + other.responseBuilder == responseBuilder && + other.messageSender == messageSender); + + // the following is best practices when overriding operator == + @override + int get hashCode => Object.hash( + provider, + style, + welcomeMessage, + responseBuilder, + messageSender, + ); +} diff --git a/lib/src/chat_view_model/chat_view_model_client.dart b/lib/src/chat_view_model/chat_view_model_client.dart new file mode 100644 index 0000000..0d28c9b --- /dev/null +++ b/lib/src/chat_view_model/chat_view_model_client.dart @@ -0,0 +1,43 @@ +// Copyright 2024 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/widgets.dart'; + +import 'chat_view_model.dart'; +import 'chat_view_model_provider.dart'; + +/// A widget that provides access to a [ChatViewModel] and builds its child +/// using a builder function. +/// +/// This widget is typically used in conjunction with [ChatViewModelProvider] +/// to access the [ChatViewModel] from the widget tree. +@immutable +class ChatViewModelClient extends StatelessWidget { + /// Creates a [ChatViewModelClient]. + /// + /// The [builder] argument must not be null. + const ChatViewModelClient({ + required this.builder, + this.child, + super.key, + }); + + /// A function that builds a widget tree based on the current [ChatViewModel]. + /// + /// This function is called with the current [BuildContext], the + /// [ChatViewModel] obtained from the nearest [ChatViewModelProvider] + /// ancestor, and the optional [child]. + final Widget Function( + BuildContext context, ChatViewModel viewModel, Widget? child) builder; + + /// An optional child widget that can be passed to the [builder] function. + /// + /// This is useful when part of the widget subtree does not depend on the + /// [ChatViewModel] and can be shared across multiple builds. + final Widget? child; + + @override + Widget build(BuildContext context) => + builder(context, ChatViewModelProvider.of(context), child); +} diff --git a/lib/src/chat_view_model/chat_view_model_provider.dart b/lib/src/chat_view_model/chat_view_model_provider.dart new file mode 100644 index 0000000..1b54862 --- /dev/null +++ b/lib/src/chat_view_model/chat_view_model_provider.dart @@ -0,0 +1,53 @@ +// Copyright 2024 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/widgets.dart'; + +import 'chat_view_model.dart'; + +/// A provider widget that makes a [ChatViewModel] available to its descendants. +/// +/// This widget uses the [InheritedWidget] mechanism to efficiently propagate +/// the [ChatViewModel] down the widget tree. +@immutable +class ChatViewModelProvider extends InheritedWidget { + /// Creates a [ChatViewModelProvider]. + /// + /// The [child] and [viewModel] arguments must not be null. + const ChatViewModelProvider({ + required super.child, + required this.viewModel, + super.key, + }); + + /// The [ChatViewModel] to be made available to descendants. + final ChatViewModel viewModel; + + /// Retrieves the [ChatViewModel] from the closest [ChatViewModelProvider] + /// ancestor in the widget tree. + /// + /// This method will assert if no [ChatViewModelProvider] is found in the + /// widget's ancestors. + /// + /// [context] must not be null. + static ChatViewModel of(BuildContext context) { + final viewModel = maybeOf(context); + assert(viewModel != null, 'No ChatViewModelProvider found in context'); + return viewModel!; + } + + /// Retrieves the [ChatViewModel] from the closest [ChatViewModelProvider] + /// ancestor in the widget tree, if one exists. + /// + /// Returns null if no [ChatViewModelProvider] is found. + /// + /// [context] must not be null. + static ChatViewModel? maybeOf(BuildContext context) => context + .dependOnInheritedWidgetOfExactType() + ?.viewModel; + + @override + bool updateShouldNotify(ChatViewModelProvider oldWidget) => + viewModel != oldWidget.viewModel; +} diff --git a/lib/src/dialogs/adaptive_dialog.dart b/lib/src/dialogs/adaptive_dialog.dart new file mode 100644 index 0000000..1f4534a --- /dev/null +++ b/lib/src/dialogs/adaptive_dialog.dart @@ -0,0 +1,50 @@ +// Copyright 2024 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/cupertino.dart' + show CupertinoAlertDialog, showCupertinoDialog; +import 'package:flutter/material.dart' show AlertDialog, showDialog; +import 'package:flutter/widgets.dart'; + +import '../utility.dart'; + +/// A utility class for showing adaptive dialogs that match the current platform +/// style. +@immutable +class AdaptiveAlertDialog { + /// Shows an adaptive dialog with the given [content] widget as content. + /// + /// This method automatically chooses between a Cupertino-style dialog for iOS + /// and a Material-style dialog for other platforms. + /// + /// Parameters: + /// * [context]: The build context in which to show the dialog. + /// * [child]: The widget to display as the dialog's content. + /// + /// Returns a [Future] that completes with the result value when the dialog is + /// dismissed. + static Future show({ + required BuildContext context, + required Widget content, + bool barrierDismissible = false, + List actions = const [], + }) => + isCupertinoApp(context) + ? showCupertinoDialog( + context: context, + barrierDismissible: barrierDismissible, + builder: (context) => CupertinoAlertDialog( + content: content, + actions: actions, + ), + ) + : showDialog( + context: context, + barrierDismissible: barrierDismissible, + builder: (context) => AlertDialog( + content: content, + actions: actions, + ), + ); +} diff --git a/lib/src/dialogs/adaptive_dialog_action.dart b/lib/src/dialogs/adaptive_dialog_action.dart new file mode 100644 index 0000000..b3dd404 --- /dev/null +++ b/lib/src/dialogs/adaptive_dialog_action.dart @@ -0,0 +1,43 @@ +// Copyright 2024 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/cupertino.dart' show CupertinoDialogAction; +import 'package:flutter/material.dart' show TextButton; +import 'package:flutter/widgets.dart'; + +import '../utility.dart'; + +/// A button that adapts its appearance based on the design language, either +/// Material or Cupertino. +/// +/// The [AdaptiveDialogAction] widget is designed to provide a consistent user +/// experience across different platforms while adhering to platform-specific +/// design guidelines. +@immutable +class AdaptiveDialogAction extends StatelessWidget { + /// Creates an adaptive dialog action. + /// + /// The [onPressed] and [child] arguments must not be null. + const AdaptiveDialogAction({ + required this.onPressed, + required this.child, + super.key, + }); + + /// The callback that is called when the button is tapped or pressed. + final VoidCallback onPressed; + + /// The widget below this widget in the tree. + /// + /// Typically a [Text] widget. + final Widget child; + + @override + Widget build(BuildContext context) => isCupertinoApp(context) + ? CupertinoDialogAction( + onPressed: onPressed, + child: child, + ) + : TextButton(onPressed: onPressed, child: child); +} diff --git a/lib/src/dialogs/adaptive_snack_bar/adaptive_snack_bar.dart b/lib/src/dialogs/adaptive_snack_bar/adaptive_snack_bar.dart new file mode 100644 index 0000000..d7d7f63 --- /dev/null +++ b/lib/src/dialogs/adaptive_snack_bar/adaptive_snack_bar.dart @@ -0,0 +1,54 @@ +// Copyright 2024 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart' show ScaffoldMessenger, SnackBar; +import 'package:flutter/widgets.dart'; + +import '../../utility.dart'; +import 'cupertino_snack_bar.dart'; + +/// A utility class for showing adaptive snack bars in Flutter applications. +/// +/// This class provides a static method to display snack bars that adapt to the +/// current application environment, showing either a Material Design snack bar +/// or a Cupertino-style snack bar based on the app's context. +@immutable +class AdaptiveSnackBar { + /// Shows an adaptive snack bar with the given message. + /// + /// This method determines whether the app is using Cupertino or Material + /// design and displays an appropriate snack bar. + /// + /// Parameters: + /// * [context]: The build context in which to show the snack bar. + /// * [message]: The text message to display in the snack bar. + static void show(BuildContext context, String message) { + if (isCupertinoApp(context)) { + _showCupertinoSnackBar(context: context, message: message); + } else { + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(message))); + } + } + + static void _showCupertinoSnackBar({ + required BuildContext context, + required String message, + int durationMillis = 4000, + }) { + const animationDurationMillis = 200; + final overlayEntry = OverlayEntry( + builder: (context) => CupertinoSnackBar( + message: message, + animationDurationMillis: animationDurationMillis, + waitDurationMillis: durationMillis, + ), + ); + Future.delayed( + Duration(milliseconds: durationMillis + 2 * animationDurationMillis), + overlayEntry.remove, + ); + Overlay.of(context).insert(overlayEntry); + } +} diff --git a/lib/src/dialogs/adaptive_snack_bar/cupertino_snack_bar.dart b/lib/src/dialogs/adaptive_snack_bar/cupertino_snack_bar.dart new file mode 100644 index 0000000..fe102a8 --- /dev/null +++ b/lib/src/dialogs/adaptive_snack_bar/cupertino_snack_bar.dart @@ -0,0 +1,86 @@ +// Copyright 2024 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/cupertino.dart'; + +/// A widget that displays a Cupertino-style snack bar. +/// +/// This widget creates an animated snack bar that slides up from the bottom of +/// the screen, displays a message for a specified duration, and then slides +/// back down. +/// +/// The snack bar uses Cupertino styling to match iOS design guidelines. +@immutable +class CupertinoSnackBar extends StatefulWidget { + /// Creates a [CupertinoSnackBar]. + /// + /// All parameters are required: + /// * [message] is the text to display in the snack bar. + /// * [animationDurationMillis] defines how long the slide animations take. + /// * [waitDurationMillis] sets how long the snack bar stays visible before + /// dismissing. + const CupertinoSnackBar({ + required this.message, + required this.animationDurationMillis, + required this.waitDurationMillis, + super.key, + }); + + /// The message to display in the snack bar. + final String message; + + /// The duration of the slide-in and slide-out animations in milliseconds. + final int animationDurationMillis; + + /// The duration for which the snack bar remains visible in milliseconds. + final int waitDurationMillis; + + @override + State createState() => _CupertinoSnackBarState(); +} + +class _CupertinoSnackBarState extends State { + bool show = false; + + @override + void initState() { + super.initState(); + Future.microtask(() => setState(() => show = true)); + Future.delayed( + Duration( + milliseconds: widget.waitDurationMillis, + ), + () { + if (mounted) { + setState(() => show = false); + } + }, + ); + } + + @override + Widget build(BuildContext context) => AnimatedPositioned( + bottom: show ? 8.0 : -50.0, + left: 8, + right: 8, + curve: show ? Curves.linearToEaseOut : Curves.easeInToLinear, + duration: Duration(milliseconds: widget.animationDurationMillis), + child: CupertinoPopupSurface( + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 8, + ), + child: Text( + widget.message, + style: const TextStyle( + fontSize: 14, + color: CupertinoColors.secondaryLabel, + ), + textAlign: TextAlign.center, + ), + ), + ), + ); +} diff --git a/lib/src/dialogs/image_preview_dialog.dart b/lib/src/dialogs/image_preview_dialog.dart new file mode 100644 index 0000000..8673257 --- /dev/null +++ b/lib/src/dialogs/image_preview_dialog.dart @@ -0,0 +1,26 @@ +// Copyright 2024 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/widgets.dart'; + +import '../providers/interface/attachments.dart'; + +/// Displays a dialog to preview the image when the user taps on an attached +/// image. +@immutable +class ImagePreviewDialog extends StatelessWidget { + /// Shows the [ImagePreviewDialog] for the given [attachment]. + const ImagePreviewDialog(this.attachment, {super.key}); + + /// The image file attachment to be previewed in the dialog. + final ImageFileAttachment attachment; + + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.all(8), + child: Center( + child: Image.memory(attachment.bytes, fit: BoxFit.contain), + ), + ); +} diff --git a/lib/src/llm_exception.dart b/lib/src/llm_exception.dart new file mode 100644 index 0000000..be5b4c9 --- /dev/null +++ b/lib/src/llm_exception.dart @@ -0,0 +1,52 @@ +// Copyright 2024 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/foundation.dart'; + +/// Exception class for LLM-related errors. +/// +/// This class is used to represent exceptions that occur during +/// LLM (Language Learning Model) operations. +@immutable +abstract class LlmException implements Exception { + /// Creates a new [LlmException] with the given error [message]. + /// + /// The [message] parameter is a string describing the error that occurred. + const LlmException._([this.message = '']); + + /// The message describing the error that occurred. + final String message; + + @override + String toString() => 'LlmException: $message'; +} + +/// Exception thrown when an LLM operation is cancelled. +/// +/// This exception is used to indicate that an LLM operation was +/// intentionally cancelled, typically by user action or a timeout. +@immutable +class LlmCancelException extends LlmException { + /// Creates a new [LlmCancelException]. + const LlmCancelException() : super._(); + + @override + String toString() => 'LlmCancelException'; +} + +/// Exception thrown when an LLM operation fails. +/// +/// This exception is used to represent failures in LLM operations +/// that are not due to cancellation, such as network errors or +/// invalid responses from the LLM provider. +@immutable +class LlmFailureException extends LlmException { + /// Creates a new [LlmFailureException] with the given error [message]. + /// + /// The [message] parameter is a string describing the failure that occurred. + const LlmFailureException([super.message]) : super._(); + + @override + String toString() => 'LlmFailureException: $message'; +} diff --git a/lib/src/platform_helper/platform_helper.dart b/lib/src/platform_helper/platform_helper.dart new file mode 100644 index 0000000..68e2e79 --- /dev/null +++ b/lib/src/platform_helper/platform_helper.dart @@ -0,0 +1,6 @@ +// Copyright 2024 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +export 'platform_helper_web.dart' + if (dart.library.io) 'platform_helper_io.dart'; diff --git a/lib/src/platform_helper/platform_helper_io.dart b/lib/src/platform_helper/platform_helper_io.dart new file mode 100644 index 0000000..eb8470f --- /dev/null +++ b/lib/src/platform_helper/platform_helper_io.dart @@ -0,0 +1,50 @@ +// Copyright 2024 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:io'; + +import 'package:flutter/widgets.dart'; +import 'package:image_picker/image_picker.dart'; + +/// Deletes a file from the file system. +/// +/// This method takes an [XFile] object and deletes the corresponding file +/// from the file system. It uses the [File] class from dart:io to perform +/// the deletion. +/// +/// Parameters: +/// - file: An [XFile] object representing the file to be deleted. +/// +/// Returns: +/// A [Future] that completes when the file has been deleted. +/// +/// Throws: +/// - [FileSystemException] if the file cannot be deleted. +Future deleteFile(XFile file) async { + await File(file.path).delete(); +} + +/// Checks if the device can take a photo. +/// +/// This method returns `true` if the device supports taking photos using +/// the camera, and `false` otherwise. It uses the [ImagePicker] class +/// to check for camera support. +/// +/// Returns: +/// A [bool] indicating whether the device can take a photo. +bool canTakePhoto() => ImagePicker().supportsImageSource(ImageSource.camera); + +/// Opens a dialog to take a photo using the device's camera. +/// +/// This method displays a camera interface to the user, allowing them to +/// capture a photo. The captured photo is returned as an [XFile] object. +/// +/// Parameters: +/// - context: The build context in which to show the camera dialog. +/// +/// Returns: +/// A [Future] that completes with an [XFile] object representing the +/// captured photo, or `null` if the photo capture was canceled. +Future takePhoto(BuildContext context) => + ImagePicker().pickImage(source: ImageSource.camera); diff --git a/lib/src/platform_helper/platform_helper_web.dart b/lib/src/platform_helper/platform_helper_web.dart new file mode 100644 index 0000000..868d3c9 --- /dev/null +++ b/lib/src/platform_helper/platform_helper_web.dart @@ -0,0 +1,45 @@ +// Copyright 2024 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:cross_file/cross_file.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_picture_taker/flutter_picture_taker.dart'; + +/// Deletes a file from the file system. +/// +/// This method is a no-op on web platforms, as web browsers do not have +/// direct access to the file system. The method is provided for API +/// compatibility with non-web platforms. +/// +/// Parameters: +/// - file: An [XFile] object representing the file to be deleted. +/// This parameter is ignored on web platforms. +/// +/// Returns: +/// A [Future] that completes immediately, as no actual deletion occurs. +Future deleteFile(XFile file) async {} + +/// Checks if the device can take a photo. +/// +/// This method always returns `true` on web platforms, as the capability +/// to take a photo is assumed to be available via the flutter_picture_taker +/// package. +/// +/// Returns: +/// A [bool] indicating whether the device can take a photo. +bool canTakePhoto() => true; + +/// Opens a dialog to take a photo using the device's camera. +/// +/// This method displays a camera interface to the user, allowing them to +/// capture a photo. The captured photo is returned as an [XFile] object. +/// +/// Parameters: +/// - context: The build context in which to show the camera dialog. +/// +/// Returns: +/// A [Future] that completes with an [XFile] object representing the +/// captured photo, or `null` if the photo capture was canceled. +Future takePhoto(BuildContext context) => + showStillCameraDialog(context); diff --git a/lib/src/providers/implementations/echo_provider.dart b/lib/src/providers/implementations/echo_provider.dart new file mode 100644 index 0000000..061ff19 --- /dev/null +++ b/lib/src/providers/implementations/echo_provider.dart @@ -0,0 +1,83 @@ +// Copyright 2024 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/foundation.dart'; + +import '../../llm_exception.dart'; +import '../interface/attachments.dart'; +import '../interface/chat_message.dart'; +import '../interface/llm_provider.dart'; + +/// A simple LLM provider that echoes the input prompt and attachment +/// information. +/// +/// This provider is primarily used for testing and debugging purposes. +class EchoProvider extends LlmProvider with ChangeNotifier { + /// Creates an [EchoProvider] instance with an optional chat history. + /// + /// The [history] parameter is an optional iterable of [ChatMessage] objects + /// representing the chat history. If provided, it will be converted to a list + /// and stored internally. If not provided, an empty list will be used. + EchoProvider({Iterable? history}) + : _history = List.from(history ?? []); + + final List _history; + + @override + Stream generateStream( + String prompt, { + Iterable attachments = const [], + }) async* { + if (prompt == 'FAILFAST') throw const LlmFailureException('Failing fast!'); + + await Future.delayed(const Duration(milliseconds: 1000)); + yield '# Echo\n'; + + switch (prompt) { + case 'CANCEL': + throw const LlmCancelException(); + case 'FAIL': + throw const LlmFailureException('User requested failure'); + } + + await Future.delayed(const Duration(milliseconds: 1000)); + yield prompt; + + yield '\n\n# Attachments\n${attachments.map((a) => a.toString())}'; + } + + @override + Stream sendMessageStream( + String prompt, { + Iterable attachments = const [], + }) async* { + final userMessage = ChatMessage.user(prompt, attachments); + final llmMessage = ChatMessage.llm(); + _history.addAll([userMessage, llmMessage]); + final response = generateStream(prompt, attachments: attachments); + + // don't write this code if you're targeting the web until this is fixed: + // https://github.com/dart-lang/sdk/issues/47764 + // await for (final chunk in chunks) { + // llmMessage.append(chunk); + // yield chunk; + // } + yield* response.map((chunk) { + llmMessage.append(chunk); + return chunk; + }); + + notifyListeners(); + } + + @override + Iterable get history => _history; + + @override + set history(Iterable history) { + _history.clear(); + _history.addAll(history); + notifyListeners(); + } +} diff --git a/lib/src/providers/implementations/gemini_provider.dart b/lib/src/providers/implementations/gemini_provider.dart new file mode 100644 index 0000000..7811d34 --- /dev/null +++ b/lib/src/providers/implementations/gemini_provider.dart @@ -0,0 +1,143 @@ +// Copyright 2024 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/foundation.dart'; +import 'package:google_generative_ai/google_generative_ai.dart'; + +import '../interface/attachments.dart'; +import '../interface/chat_message.dart'; +import '../interface/llm_provider.dart'; + +/// A provider class for interacting with Google's Gemini AI language model. +/// +/// This class extends [LlmProvider] and implements the necessary methods to +/// generate text using the Gemini AI model. +class GeminiProvider extends LlmProvider with ChangeNotifier { + /// Creates a new instance of [GeminiProvider]. + /// + /// [model] is an optional [GenerativeModel] instance for text generation. If + /// provided, it will be used for chat-based interactions and text generation. + /// + /// [history] is an optional list of previous chat messages to initialize the + /// chat session with. + /// + /// [chatSafetySettings] is an optional list of safety settings to apply to + /// the model's responses. + /// + /// [chatGenerationConfig] is an optional configuration for controlling the + /// model's generation behavior. + @immutable + GeminiProvider({ + required GenerativeModel model, + Iterable? history, + List? chatSafetySettings, + GenerationConfig? chatGenerationConfig, + }) : _model = model, + _history = history?.toList() ?? [], + _chatSafetySettings = chatSafetySettings, + _chatGenerationConfig = chatGenerationConfig { + _chat = _startChat(history); + } + + final GenerativeModel _model; + final List? _chatSafetySettings; + final GenerationConfig? _chatGenerationConfig; + final List _history; + ChatSession? _chat; + + @override + Stream generateStream( + String prompt, { + Iterable attachments = const [], + }) => + _generateStream( + prompt: prompt, + attachments: attachments, + contentStreamGenerator: (c) => _model.generateContentStream([c]), + ); + + @override + Stream sendMessageStream( + String prompt, { + Iterable attachments = const [], + }) async* { + final userMessage = ChatMessage.user(prompt, attachments); + final llmMessage = ChatMessage.llm(); + _history.addAll([userMessage, llmMessage]); + + final response = _generateStream( + prompt: prompt, + attachments: attachments, + contentStreamGenerator: _chat!.sendMessageStream, + ); + + // don't write this code if you're targeting the web until this is fixed: + // https://github.com/dart-lang/sdk/issues/47764 + // await for (final chunk in response) { + // llmMessage.append(chunk); + // yield chunk; + // } + yield* response.map((chunk) { + llmMessage.append(chunk); + return chunk; + }); + + // notify listeners that the history has changed when response is complete + notifyListeners(); + } + + Stream _generateStream({ + required String prompt, + required Iterable attachments, + required Stream Function(Content) + contentStreamGenerator, + }) async* { + final content = Content('user', [ + TextPart(prompt), + ...attachments.map(_partFrom), + ]); + + final response = contentStreamGenerator(content); + // don't write this code if you're targeting the web until this is fixed: + // https://github.com/dart-lang/sdk/issues/47764 + // await for (final chunk in response) { + // final text = chunk.text; + // if (text != null) yield text; + // } + yield* response + .map((chunk) => chunk.text) + .where((text) => text != null) + .cast(); + } + + @override + Iterable get history => _history; + + @override + set history(Iterable history) { + _history.clear(); + _history.addAll(history); + _chat = _startChat(history); + notifyListeners(); + } + + ChatSession? _startChat(Iterable? history) => _model.startChat( + history: history?.map(_contentFrom).toList(), + safetySettings: _chatSafetySettings, + generationConfig: _chatGenerationConfig, + ); + + static Part _partFrom(Attachment attachment) => switch (attachment) { + (final FileAttachment a) => DataPart(a.mimeType, a.bytes), + (final LinkAttachment a) => FilePart(a.url), + }; + + static Content _contentFrom(ChatMessage message) => Content( + message.origin.isUser ? 'user' : 'model', + [ + TextPart(message.text ?? ''), + ...message.attachments.map(_partFrom), + ], + ); +} diff --git a/lib/src/providers/implementations/vertex_provider.dart b/lib/src/providers/implementations/vertex_provider.dart new file mode 100644 index 0000000..150108a --- /dev/null +++ b/lib/src/providers/implementations/vertex_provider.dart @@ -0,0 +1,143 @@ +// Copyright 2024 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:firebase_vertexai/firebase_vertexai.dart'; +import 'package:flutter/foundation.dart'; + +import '../interface/attachments.dart'; +import '../interface/chat_message.dart'; +import '../interface/llm_provider.dart'; + +/// A provider class for interacting with Firebase Vertex AI's language model. +/// +/// This class extends [LlmProvider] and implements the necessary methods to +/// generate text using Firebase Vertex AI's generative model. +class VertexProvider extends LlmProvider with ChangeNotifier { + /// Creates a new instance of [VertexProvider]. + /// + /// [model] is an optional [GenerativeModel] instance for text generation. If + /// provided, it will be used for chat-based interactions and text generation. + /// + /// [history] is an optional list of previous chat messages to initialize the + /// chat session with. + /// + /// [chatSafetySettings] is an optional list of safety settings to apply to + /// the model's responses. + /// + /// [chatGenerationConfig] is an optional configuration for controlling the + /// model's generation behavior. + @immutable + VertexProvider({ + required GenerativeModel model, + Iterable? history, + List? chatSafetySettings, + GenerationConfig? chatGenerationConfig, + }) : _model = model, + _history = history?.toList() ?? [], + _chatSafetySettings = chatSafetySettings, + _chatGenerationConfig = chatGenerationConfig { + _chat = _startChat(history); + } + + final GenerativeModel _model; + final List? _chatSafetySettings; + final GenerationConfig? _chatGenerationConfig; + final List _history; + ChatSession? _chat; + + @override + Stream generateStream( + String prompt, { + Iterable attachments = const [], + }) => + _generateStream( + prompt: prompt, + attachments: attachments, + contentStreamGenerator: (c) => _model.generateContentStream([c]), + ); + + @override + Stream sendMessageStream( + String prompt, { + Iterable attachments = const [], + }) async* { + final userMessage = ChatMessage.user(prompt, attachments); + final llmMessage = ChatMessage.llm(); + _history.addAll([userMessage, llmMessage]); + + final response = _generateStream( + prompt: prompt, + attachments: attachments, + contentStreamGenerator: _chat!.sendMessageStream, + ); + + // don't write this code if you're targeting the web until this is fixed: + // https://github.com/dart-lang/sdk/issues/47764 + // await for (final chunk in response) { + // llmMessage.append(chunk); + // yield chunk; + // } + yield* response.map((chunk) { + llmMessage.append(chunk); + return chunk; + }); + + // notify listeners that the history has changed when response is complete + notifyListeners(); + } + + Stream _generateStream({ + required String prompt, + required Iterable attachments, + required Stream Function(Content) + contentStreamGenerator, + }) async* { + final content = Content('user', [ + TextPart(prompt), + ...attachments.map(_partFrom), + ]); + + final response = contentStreamGenerator(content); + // don't write this code if you're targeting the web until this is fixed: + // https://github.com/dart-lang/sdk/issues/47764 + // await for (final chunk in response) { + // final text = chunk.text; + // if (text != null) yield text; + // } + yield* response + .map((chunk) => chunk.text) + .where((text) => text != null) + .cast(); + } + + @override + Iterable get history => _history; + + @override + set history(Iterable history) { + _history.clear(); + _history.addAll(history); + _chat = _startChat(history); + notifyListeners(); + } + + ChatSession? _startChat(Iterable? history) => _model.startChat( + history: history?.map(_contentFrom).toList(), + safetySettings: _chatSafetySettings, + generationConfig: _chatGenerationConfig, + ); + + static Part _partFrom(Attachment attachment) => switch (attachment) { + (final FileAttachment a) => InlineDataPart(a.mimeType, a.bytes), + (final LinkAttachment a) => FileData(a.mimeType, a.url.toString()), + }; + + static Content _contentFrom(ChatMessage message) => Content( + message.origin.isUser ? 'user' : 'model', + [ + TextPart(message.text ?? ''), + ...message.attachments.map(_partFrom), + ], + ); +} diff --git a/lib/src/providers/interface/attachments.dart b/lib/src/providers/interface/attachments.dart new file mode 100644 index 0000000..8514340 --- /dev/null +++ b/lib/src/providers/interface/attachments.dart @@ -0,0 +1,185 @@ +// Copyright 2024 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:cross_file/cross_file.dart'; +import 'package:flutter/foundation.dart'; +import 'package:mime/mime.dart'; + +/// An abstract class representing an attachment in a chat message. +/// +/// This class serves as a base for different types of attachments +/// (e.g., files, images, links) that can be included in a chat message. +@immutable +sealed class Attachment { + /// Creates an [Attachment] with the given name. + /// + /// [name] is the name of the attachment, which must not be null. + const Attachment({required this.name}); + + /// The name of the attachment. + final String name; + + static String _mimeType(XFile file) => + file.mimeType ?? lookupMimeType(file.name) ?? 'application/octet-stream'; + + static bool _isImage(String mimeType) => + mimeType.toLowerCase().startsWith('image/'); +} + +/// Represents a file attachment in a chat message. +/// +/// This class extends [Attachment] and provides specific properties and methods +/// for handling file attachments. +@immutable +final class FileAttachment extends Attachment { + /// Creates a [FileAttachment] with the given name, MIME type, and bytes. + /// + /// [name] is the name of the file attachment. + /// [mimeType] is the MIME type of the file. + /// [bytes] is the binary content of the file. + const FileAttachment({ + required super.name, + required this.mimeType, + required this.bytes, + }); + + /// Factory constructor for creating either a [FileAttachment] or an + /// [ImageFileAttachment]. + /// + /// This factory method determines the type of attachment based on the MIME + /// type. If the MIME type indicates an image, it creates an + /// [ImageFileAttachment]. Otherwise, it creates a [FileAttachment]. + /// + /// [name] is the name of the attachment. [mimeType] is the MIME type of the + /// attachment. [bytes] is the binary content of the attachment. + /// + /// Returns an instance of either [FileAttachment] or [ImageFileAttachment]. + factory FileAttachment.fileOrImage({ + required String name, + required String mimeType, + required Uint8List bytes, + }) => + Attachment._isImage(mimeType) + ? ImageFileAttachment( + name: name, + mimeType: mimeType, + bytes: bytes, + ) + : FileAttachment( + name: name, + mimeType: mimeType, + bytes: bytes, + ); + + /// The MIME type of the file attachment. + final String mimeType; + + /// The binary content of the file attachment. + final Uint8List bytes; + + @override + String toString() => 'FileAttachment(' + 'name: $name, ' + 'mimeType: $mimeType, ' + // I want to avoid the trailing whitespace here for readability. + // ignore: missing_whitespace_between_adjacent_strings + 'bytes: ${bytes.length} bytes' + ')'; + + /// Creates a [FileAttachment] from an [XFile]. + /// + /// This factory method asynchronously reads the file content and determines + /// its MIME type. + /// + /// [file] is the XFile object representing the file to be attached. + /// + /// Returns a Future that completes with a [FileAttachment] instance. + static Future fromFile(XFile file) async => + FileAttachment.fileOrImage( + name: file.name, + mimeType: Attachment._mimeType(file), + bytes: await file.readAsBytes(), + ); +} + +/// Represents an image attachment in a chat message. +/// +/// This class extends [Attachment] and provides specific properties and methods +/// for handling image attachments. +@immutable +final class ImageFileAttachment extends FileAttachment { + /// Creates an [ImageFileAttachment] with the given name, MIME type, and + /// bytes. + /// + /// [name] is the name of the image attachment. [mimeType] is the MIME type of + /// the image. [bytes] is the binary content of the image. + ImageFileAttachment({ + required super.name, + required super.mimeType, + required super.bytes, + }) : assert(Attachment._isImage(mimeType)); + + @override + String toString() => 'ImageFileAttachment(' + 'name: $name, ' + 'mimeType: $mimeType, ' + // I want to avoid the trailing whitespace here for readability. + // ignore: missing_whitespace_between_adjacent_strings + 'bytes: ${bytes.length} bytes' + ')'; + + /// Creates an [ImageFileAttachment] from an [XFile]. + /// + /// This factory method asynchronously reads the file content and determines + /// its MIME type. It throws an exception if the file is not an image. + /// + /// [file] is the XFile object representing the image file to be attached. + /// + /// Returns a Future that completes with an [ImageFileAttachment] instance. + /// Throws an Exception if the file is not an image. + static Future fromFile(XFile file) async { + final mimeType = Attachment._mimeType(file); + if (!Attachment._isImage(mimeType)) { + throw Exception('Not an image: $mimeType'); + } + + return ImageFileAttachment( + name: file.name, + mimeType: mimeType, + bytes: await file.readAsBytes(), + ); + } +} + +/// Represents a link attachment in a chat message. +/// +/// This class extends [Attachment] and provides specific properties for +/// handling link attachments. +@immutable +final class LinkAttachment extends Attachment { + /// Creates a [LinkAttachment] with the given name and URL. + /// + /// [name] is the name of the link attachment. + /// [url] is the URI of the link. + LinkAttachment({ + required super.name, + required this.url, + }) : mimeType = lookupMimeType(url.path) ?? 'application/octet-stream'; + + /// The URL of the link attachment. + final Uri url; + + /// The MIME type of the linked content. + /// + /// This property specifies the media type of the resource pointed to by the + /// [url]. + final String mimeType; + + @override + String toString() => 'LinkAttachment(' + 'name: $name, ' + 'url: $url, ' + 'mimeType: $mimeType' + ')'; +} diff --git a/lib/src/providers/interface/chat_message.dart b/lib/src/providers/interface/chat_message.dart new file mode 100644 index 0000000..57f406f --- /dev/null +++ b/lib/src/providers/interface/chat_message.dart @@ -0,0 +1,137 @@ +// Copyright 2024 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// using dynamic calls to translate to/from JSON +// ignore_for_file: avoid_dynamic_calls + +import 'dart:convert'; + +import '../../providers/interface/attachments.dart'; +import 'message_origin.dart'; + +/// Represents a message in a chat conversation. +/// +/// This class encapsulates the properties and behavior of a chat message, +/// including its unique identifier, origin (user or LLM), text content, +/// and any attachments. +class ChatMessage { + /// Constructs a [ChatMessage] instance. + /// + /// The [origin] parameter specifies the origin of the message (user or LLM). + /// The [text] parameter is the content of the message. It can be null or + /// empty if the message is from an LLM. For user-originated messages, [text] + /// must not be null or empty. The [attachments] parameter is a list of any + /// files or media attached to the message. + ChatMessage({ + required this.origin, + required this.text, + required this.attachments, + }) : assert(origin.isUser && text != null && text.isNotEmpty || origin.isLlm); + + /// Converts a JSON map representation to a [ChatMessage]. + /// + /// The map should contain the following keys: + /// - 'origin': The origin of the message (user or model). + /// - 'text': The text content of the message. + /// - 'attachments': A list of attachments, each represented as a map with: + /// - 'type': The type of the attachment ('file' or 'link'). + /// - 'name': The name of the attachment. + /// - 'mimeType': The MIME type of the attachment. + /// - 'data': The data of the attachment, either as a base64 encoded string + /// (for files) or a URL (for links). + factory ChatMessage.fromJson(Map map) => ChatMessage( + origin: MessageOrigin.values.byName(map['origin'] as String), + text: map['text'] as String, + attachments: [ + for (final attachment in map['attachments'] as List) + switch (attachment['type'] as String) { + 'file' => FileAttachment.fileOrImage( + name: attachment['name'] as String, + mimeType: attachment['mimeType'] as String, + bytes: base64Decode(attachment['data'] as String), + ), + 'link' => LinkAttachment( + name: attachment['name'] as String, + url: Uri.parse(attachment['data'] as String), + ), + _ => throw UnimplementedError(), + }, + ], + ); + + /// Factory constructor for creating an LLM-originated message. + /// + /// Creates a message with an empty text content and no attachments. + factory ChatMessage.llm() => ChatMessage( + origin: MessageOrigin.llm, + text: null, + attachments: [], + ); + + /// Factory constructor for creating a user-originated message. + /// + /// [text] is the content of the user's message. + /// [attachments] are any files or media the user has attached to the message. + factory ChatMessage.user(String text, Iterable attachments) => + ChatMessage( + origin: MessageOrigin.user, + text: text, + attachments: attachments, + ); + + /// Text content of the message. + String? text; + + /// The origin of the message (user or LLM). + final MessageOrigin origin; + + /// Any attachments associated with the message. + final Iterable attachments; + + /// Appends additional text to the existing message content. + /// + /// This is typically used for LLM messages that are streamed in parts. + void append(String text) => this.text = (this.text ?? '') + text; + + @override + String toString() => 'ChatMessage(' + 'origin: $origin, ' + 'text: $text, ' + 'attachments: $attachments' + ')'; + + /// Converts a [ChatMessage] to a JSON map representation. + /// + /// The map contains the following keys: + /// - 'origin': The origin of the message (user or model). + /// - 'text': The text content of the message. + /// - 'attachments': A list of attachments, each represented as a map with: + /// - 'type': The type of the attachment ('file' or 'link'). + /// - 'name': The name of the attachment. + /// - 'mimeType': The MIME type of the attachment. + /// - 'data': The data of the attachment, either as a base64 encoded string + /// (for files) or a URL (for links). + Map toJson() => { + 'origin': origin.name, + 'text': text, + 'attachments': [ + for (final attachment in attachments) + { + 'type': switch (attachment) { + (FileAttachment _) => 'file', + (LinkAttachment _) => 'link', + }, + 'name': attachment.name, + 'mimeType': switch (attachment) { + (final FileAttachment a) => a.mimeType, + (final LinkAttachment a) => a.mimeType, + }, + 'data': switch (attachment) { + (final FileAttachment a) => base64Encode(a.bytes), + (final LinkAttachment a) => a.url, + }, + }, + ], + }; +} diff --git a/lib/src/providers/interface/llm_provider.dart b/lib/src/providers/interface/llm_provider.dart new file mode 100644 index 0000000..17fc76c --- /dev/null +++ b/lib/src/providers/interface/llm_provider.dart @@ -0,0 +1,76 @@ +// Copyright 2024 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/foundation.dart'; + +import 'attachments.dart'; +import 'chat_message.dart'; + +/// An abstract class representing a Language Model (LLM) provider. +/// +/// This class defines the interface for interacting with different LLM +/// services. Implementations of this class should provide the logic for +/// generating text responses based on input prompts and optional attachments. +abstract class LlmProvider implements Listenable { + /// Generates a stream of text based on the given prompt and attachments. + /// This method does not interact with a chat or build on any chat history. + /// + /// [prompt] is the input text to generate a response for. + /// [attachments] is an optional iterable of [Attachment] objects to include + /// with the prompt. These can be images, files, or links that provide + /// additional context for the LLM. + /// + /// Returns a [Stream] of [String] containing the generated text chunks. This + /// allows for streaming responses as they are generated by the LLM. + Stream generateStream( + String prompt, { + Iterable attachments, + }); + + /// Generates a stream of text based on the given prompt and attachments. + /// Interacts with a chat and builds on the history of the chat associated + /// with the provider. + /// + /// This method should be implemented to interact with the specific LLM + /// service and generate text responses. + /// + /// [prompt] is the input text to generate a response for. [attachments] is an + /// optional iterable of [Attachment] objects to include with the prompt. + /// These can be images, files, or links that provide additional context for + /// the LLM. + /// + /// Returns a [Stream] of [String] containing the generated text chunks. This + /// allows for streaming responses as they are generated by the LLM. + Stream sendMessageStream( + String prompt, { + Iterable attachments, + }); + + /// Returns an iterable of [ChatMessage] objects representing the chat + /// history. + /// + /// This getter provides access to the conversation history maintained by the + /// LLM provider. The history typically includes both user messages and LLM + /// responses in chronological order. + /// + /// Returns an [Iterable] of [ChatMessage] objects. + Iterable get history; + + /// Sets the chat history to the provided messages. + /// + /// This setter allows updating the conversation history maintained by the LLM + /// provider. The provided [history] replaces the existing history with a new + /// set of messages. + /// + /// [history] is an [Iterable] of [ChatMessage] objects representing the new + /// chat history. + set history(Iterable history); +} + +/// A function that generates a stream of text based on a prompt and +/// attachments. +typedef LlmStreamGenerator = Stream Function( + String prompt, { + required Iterable attachments, +}); diff --git a/lib/src/providers/interface/message_origin.dart b/lib/src/providers/interface/message_origin.dart new file mode 100644 index 0000000..e048577 --- /dev/null +++ b/lib/src/providers/interface/message_origin.dart @@ -0,0 +1,18 @@ +// Copyright 2024 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +/// Represents the origin of a chat message. +enum MessageOrigin { + /// Indicates that the message originated from the user. + user, + + /// Indicates that the message originated from the LLM. + llm; + + /// Checks if the message origin is from the user. + bool get isUser => this == MessageOrigin.user; + + /// Checks if the message origin is from the LLM. + bool get isLlm => this == MessageOrigin.llm; +} diff --git a/lib/src/providers/providers.dart b/lib/src/providers/providers.dart new file mode 100644 index 0000000..a79cd1a --- /dev/null +++ b/lib/src/providers/providers.dart @@ -0,0 +1,11 @@ +// Copyright 2024 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +export 'implementations/echo_provider.dart'; +export 'implementations/gemini_provider.dart'; +export 'implementations/vertex_provider.dart'; +export 'interface/attachments.dart'; +export 'interface/chat_message.dart'; +export 'interface/llm_provider.dart'; +export 'interface/message_origin.dart'; diff --git a/lib/src/styles/action_button_style.dart b/lib/src/styles/action_button_style.dart new file mode 100644 index 0000000..42dac08 --- /dev/null +++ b/lib/src/styles/action_button_style.dart @@ -0,0 +1,149 @@ +// Copyright 2024 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/widgets.dart'; + +import 'action_button_type.dart'; +import 'tookit_icons.dart'; +import 'toolkit_colors.dart'; +import 'toolkit_text_styles.dart'; + +/// Style for icon buttons. +@immutable +class ActionButtonStyle { + /// Creates an IconButtonStyle. + const ActionButtonStyle({ + this.icon, + this.iconColor, + this.iconDecoration, + this.tooltip, + this.tooltipTextStyle, + this.tooltipDecoration, + }); + + /// Resolves the provided [style] with the [defaultStyle]. + /// + /// This method returns a new [ActionButtonStyle] instance where each property + /// is taken from the provided [style] if it is not null, otherwise from the + /// [defaultStyle]. + /// + /// - [style]: The style to resolve. If null, the [defaultStyle] will be used. + /// - [defaultStyle]: The default style to use for any properties not provided + /// by the [style]. + factory ActionButtonStyle.resolve( + ActionButtonStyle? style, { + required ActionButtonStyle defaultStyle, + }) => + ActionButtonStyle( + icon: style?.icon ?? defaultStyle.icon, + iconColor: style?.iconColor ?? defaultStyle.iconColor, + iconDecoration: style?.iconDecoration ?? defaultStyle.iconDecoration, + tooltip: style?.tooltip ?? defaultStyle.tooltip, + tooltipTextStyle: + style?.tooltipTextStyle ?? defaultStyle.tooltipTextStyle, + tooltipDecoration: + style?.tooltipDecoration ?? defaultStyle.tooltipDecoration, + ); + + /// Provides default style for icon buttons. + factory ActionButtonStyle.defaultStyle(ActionButtonType type) => + ActionButtonStyle._lightStyle(type); + + /// Provides default light style for icon buttons. + factory ActionButtonStyle._lightStyle(ActionButtonType type) { + IconData icon; + var color = ToolkitColors.darkIcon; + var bgColor = ToolkitColors.lightButtonBackground; + String tooltip; + final tooltipTextStyle = ToolkitTextStyles.tooltip; + const tooltipDecoration = BoxDecoration( + color: ToolkitColors.tooltipBackground, + borderRadius: BorderRadius.all(Radius.circular(4)), + ); + + switch (type) { + case ActionButtonType.add: + icon = ToolkitIcons.add; + tooltip = 'Add Attachment'; + case ActionButtonType.attachFile: + icon = ToolkitIcons.attach_file; + color = ToolkitColors.whiteIcon; + bgColor = ToolkitColors.darkButtonBackground; + tooltip = 'Attach File'; + case ActionButtonType.camera: + icon = ToolkitIcons.camera_alt; + color = ToolkitColors.whiteIcon; + bgColor = ToolkitColors.darkButtonBackground; + tooltip = 'Take Photo'; + case ActionButtonType.stop: + icon = ToolkitIcons.stop; + tooltip = 'Stop'; + case ActionButtonType.close: + icon = ToolkitIcons.close; + color = ToolkitColors.whiteIcon; + bgColor = ToolkitColors.darkButtonBackground; + tooltip = 'Close'; + case ActionButtonType.cancel: + icon = ToolkitIcons.close; + color = ToolkitColors.whiteIcon; + bgColor = ToolkitColors.darkButtonBackground; + tooltip = 'Cancel'; + case ActionButtonType.copy: + icon = ToolkitIcons.content_copy; + color = ToolkitColors.whiteIcon; + bgColor = ToolkitColors.darkButtonBackground; + tooltip = 'Copy to Clipboard'; + case ActionButtonType.edit: + icon = ToolkitIcons.edit; + color = ToolkitColors.whiteIcon; + bgColor = ToolkitColors.darkButtonBackground; + tooltip = 'Edit Message'; + case ActionButtonType.gallery: + icon = ToolkitIcons.image; + color = ToolkitColors.whiteIcon; + bgColor = ToolkitColors.darkButtonBackground; + tooltip = 'Image Gallery'; + case ActionButtonType.record: + icon = ToolkitIcons.mic; + tooltip = 'Record Audio'; + case ActionButtonType.submit: + icon = ToolkitIcons.submit_icon; + color = ToolkitColors.whiteIcon; + bgColor = ToolkitColors.darkButtonBackground; + tooltip = 'Submit Message'; + case ActionButtonType.closeMenu: + icon = ToolkitIcons.close; + color = ToolkitColors.whiteIcon; + bgColor = ToolkitColors.greyBackground; + tooltip = 'Close Menu'; + } + + return ActionButtonStyle( + icon: icon, + iconColor: color, + iconDecoration: BoxDecoration(color: bgColor, shape: BoxShape.circle), + tooltip: tooltip, + tooltipTextStyle: tooltipTextStyle, + tooltipDecoration: tooltipDecoration, + ); + } + + /// The icon to display for the icon button. + final IconData? icon; + + /// The color of the icon. + final Color? iconColor; + + /// The decoration for the icon. + final Decoration? iconDecoration; + + /// The tooltip for the icon button. + final String? tooltip; + + /// The text style of the tooltip. + final TextStyle? tooltipTextStyle; + + /// The decoration of the tooltip. + final Decoration? tooltipDecoration; +} diff --git a/lib/src/styles/action_button_type.dart b/lib/src/styles/action_button_type.dart new file mode 100644 index 0000000..e823f94 --- /dev/null +++ b/lib/src/styles/action_button_type.dart @@ -0,0 +1,42 @@ +// Copyright 2024 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +/// Enum representing different types of action buttons in the chat view. +enum ActionButtonType { + /// Button to add content or initiate a new action. + add, + + /// Button to attach a file to the chat. + attachFile, + + /// Button to access the camera for taking photos or videos. + camera, + + /// Button to cancel an ongoing action or input. + stop, + + /// Button to close the current view or dialog. + close, + + /// Button to close an open menu. + closeMenu, + + /// Button to cancel an operation. + cancel, + + /// Button to copy selected text or content. + copy, + + /// Button to edit existing content or settings. + edit, + + /// Button to access the device's photo gallery. + gallery, + + /// Button to start or stop audio recording. + record, + + /// Button to submit the current input or action. + submit, +} diff --git a/lib/src/styles/chat_input_style.dart b/lib/src/styles/chat_input_style.dart new file mode 100644 index 0000000..b22817d --- /dev/null +++ b/lib/src/styles/chat_input_style.dart @@ -0,0 +1,68 @@ +// Copyright 2024 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/widgets.dart'; + +import 'toolkit_colors.dart'; +import 'toolkit_text_styles.dart'; + +/// Style for the input text box. +@immutable +class ChatInputStyle { + /// Creates an InputBoxStyle. + const ChatInputStyle({ + this.textStyle, + this.hintStyle, + this.hintText, + this.backgroundColor, + this.decoration, + }); + + /// Merges the provided styles with the default styles. + factory ChatInputStyle.resolve( + ChatInputStyle? style, { + ChatInputStyle? defaultStyle, + }) { + defaultStyle ??= ChatInputStyle.defaultStyle(); + return ChatInputStyle( + textStyle: style?.textStyle ?? defaultStyle.textStyle, + hintStyle: style?.hintStyle ?? defaultStyle.hintStyle, + hintText: style?.hintText ?? defaultStyle.hintText, + backgroundColor: style?.backgroundColor ?? defaultStyle.backgroundColor, + decoration: style?.decoration ?? defaultStyle.decoration, + ); + } + + /// Provides a default style. + factory ChatInputStyle.defaultStyle() => ChatInputStyle._lightStyle(); + + /// Provides a default light style. + factory ChatInputStyle._lightStyle() => ChatInputStyle( + textStyle: ToolkitTextStyles.body2, + hintStyle: + ToolkitTextStyles.body2.copyWith(color: ToolkitColors.hintText), + hintText: 'Ask me anything...', + backgroundColor: ToolkitColors.containerBackground, + decoration: BoxDecoration( + color: ToolkitColors.containerBackground, + border: Border.all(width: 1, color: ToolkitColors.outline), + borderRadius: BorderRadius.circular(24), + ), + ); + + /// The text style for the input text box. + final TextStyle? textStyle; + + /// The hint text style for the input text box. + final TextStyle? hintStyle; + + /// The hint text for the input text box. + final String? hintText; + + /// The background color of the input box. + final Color? backgroundColor; + + /// The decoration of the input box. + final Decoration? decoration; +} diff --git a/lib/src/styles/file_attachment_style.dart b/lib/src/styles/file_attachment_style.dart new file mode 100644 index 0000000..4fa0b4f --- /dev/null +++ b/lib/src/styles/file_attachment_style.dart @@ -0,0 +1,92 @@ +// Copyright 2024 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/widgets.dart'; + +import 'tookit_icons.dart'; +import 'toolkit_colors.dart'; +import 'toolkit_text_styles.dart'; + +/// Style for file attachments in the chat view. +@immutable +class FileAttachmentStyle { + /// Creates a FileAttachmentStyle. + const FileAttachmentStyle({ + this.decoration, + this.icon, + this.iconColor, + this.iconDecoration, + this.filenameStyle, + this.filetypeStyle, + }); + + /// Resolves the FileAttachmentStyle by combining the provided style with + /// default values. + /// + /// This method takes an optional [style] and merges it with the + /// [defaultStyle]. If [defaultStyle] is not provided, it uses + /// [FileAttachmentStyle.defaultStyle]. + /// + /// [style] - The custom FileAttachmentStyle to apply. Can be null. + /// [defaultStyle] - The default FileAttachmentStyle to use as a base. If + /// null, uses [FileAttachmentStyle.defaultStyle]. + /// + /// Returns a new [FileAttachmentStyle] instance with resolved properties. + factory FileAttachmentStyle.resolve( + FileAttachmentStyle? style, { + FileAttachmentStyle? defaultStyle, + }) { + defaultStyle ??= FileAttachmentStyle.defaultStyle(); + return FileAttachmentStyle( + decoration: style?.decoration ?? defaultStyle.decoration, + icon: style?.icon ?? defaultStyle.icon, + iconColor: style?.iconColor ?? defaultStyle.iconColor, + iconDecoration: style?.iconDecoration ?? defaultStyle.iconDecoration, + filenameStyle: style?.filenameStyle ?? defaultStyle.filenameStyle, + filetypeStyle: style?.filetypeStyle ?? defaultStyle.filetypeStyle, + ); + } + + /// Provides a default style. + factory FileAttachmentStyle.defaultStyle() => + FileAttachmentStyle._lightStyle(); + + /// Provides a default light style. + factory FileAttachmentStyle._lightStyle() => FileAttachmentStyle( + decoration: ShapeDecoration( + color: ToolkitColors.fileContainerBackground, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + icon: ToolkitIcons.attach_file, + iconColor: ToolkitColors.darkIcon, + iconDecoration: ShapeDecoration( + color: ToolkitColors.fileAttachmentIconBackground, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + filenameStyle: ToolkitTextStyles.filename, + filetypeStyle: ToolkitTextStyles.filetype, + ); + + /// The decoration for the file attachment container. + final Decoration? decoration; + + /// The icon to display for the file attachment. + final IconData? icon; + + /// The color of the file attachment icon. + final Color? iconColor; + + /// The decoration for the file attachment icon container. + final Decoration? iconDecoration; + + /// The text style for the filename. + final TextStyle? filenameStyle; + + /// The text style for the filetype. + final TextStyle? filetypeStyle; +} diff --git a/lib/src/styles/llm_chat_view_style.dart b/lib/src/styles/llm_chat_view_style.dart new file mode 100644 index 0000000..d4cfbf5 --- /dev/null +++ b/lib/src/styles/llm_chat_view_style.dart @@ -0,0 +1,223 @@ +// Copyright 2024 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/widgets.dart'; + +import 'action_button_style.dart'; +import 'action_button_type.dart'; +import 'chat_input_style.dart'; +import 'file_attachment_style.dart'; +import 'llm_message_style.dart'; +import 'suggestion_style.dart'; +import 'toolkit_colors.dart'; +import 'user_message_style.dart'; + +/// Style for the entire chat widget. +@immutable +class LlmChatViewStyle { + /// Creates a style object for the chat widget. + const LlmChatViewStyle({ + this.backgroundColor, + this.progressIndicatorColor, + this.userMessageStyle, + this.llmMessageStyle, + this.chatInputStyle, + this.addButtonStyle, + this.attachFileButtonStyle, + this.cameraButtonStyle, + this.stopButtonStyle, + this.closeButtonStyle, + this.cancelButtonStyle, + this.copyButtonStyle, + this.editButtonStyle, + this.galleryButtonStyle, + this.recordButtonStyle, + this.submitButtonStyle, + this.closeMenuButtonStyle, + this.actionButtonBarDecoration, + this.fileAttachmentStyle, + this.suggestionStyle, + }); + + /// Resolves the provided [style] with the [defaultStyle]. + /// + /// This method returns a new [LlmChatViewStyle] instance where each property + /// is taken from the provided [style] if it is not null, otherwise from the + /// [defaultStyle]. + /// + /// - [style]: The style to resolve. If null, the [defaultStyle] will be used. + /// - [defaultStyle]: The default style to use for any properties not provided + /// by the [style]. + factory LlmChatViewStyle.resolve( + LlmChatViewStyle? style, { + LlmChatViewStyle? defaultStyle, + }) { + defaultStyle ??= LlmChatViewStyle.defaultStyle(); + return LlmChatViewStyle( + backgroundColor: style?.backgroundColor ?? defaultStyle.backgroundColor, + progressIndicatorColor: + style?.progressIndicatorColor ?? defaultStyle.progressIndicatorColor, + userMessageStyle: UserMessageStyle.resolve(style?.userMessageStyle, + defaultStyle: defaultStyle.userMessageStyle), + llmMessageStyle: LlmMessageStyle.resolve(style?.llmMessageStyle, + defaultStyle: defaultStyle.llmMessageStyle), + chatInputStyle: ChatInputStyle.resolve(style?.chatInputStyle, + defaultStyle: defaultStyle.chatInputStyle), + addButtonStyle: ActionButtonStyle.resolve( + style?.addButtonStyle, + defaultStyle: ActionButtonStyle.defaultStyle(ActionButtonType.add), + ), + attachFileButtonStyle: ActionButtonStyle.resolve( + style?.attachFileButtonStyle, + defaultStyle: + ActionButtonStyle.defaultStyle(ActionButtonType.attachFile), + ), + cameraButtonStyle: ActionButtonStyle.resolve( + style?.cameraButtonStyle, + defaultStyle: ActionButtonStyle.defaultStyle(ActionButtonType.camera), + ), + stopButtonStyle: ActionButtonStyle.resolve( + style?.stopButtonStyle, + defaultStyle: ActionButtonStyle.defaultStyle(ActionButtonType.stop), + ), + closeButtonStyle: ActionButtonStyle.resolve( + style?.closeButtonStyle, + defaultStyle: ActionButtonStyle.defaultStyle(ActionButtonType.close), + ), + cancelButtonStyle: ActionButtonStyle.resolve( + style?.cancelButtonStyle, + defaultStyle: ActionButtonStyle.defaultStyle(ActionButtonType.cancel), + ), + copyButtonStyle: ActionButtonStyle.resolve( + style?.copyButtonStyle, + defaultStyle: ActionButtonStyle.defaultStyle(ActionButtonType.copy), + ), + editButtonStyle: ActionButtonStyle.resolve( + style?.editButtonStyle, + defaultStyle: ActionButtonStyle.defaultStyle(ActionButtonType.edit), + ), + galleryButtonStyle: ActionButtonStyle.resolve( + style?.galleryButtonStyle, + defaultStyle: ActionButtonStyle.defaultStyle(ActionButtonType.gallery), + ), + recordButtonStyle: ActionButtonStyle.resolve( + style?.recordButtonStyle, + defaultStyle: ActionButtonStyle.defaultStyle(ActionButtonType.record), + ), + submitButtonStyle: ActionButtonStyle.resolve( + style?.submitButtonStyle, + defaultStyle: ActionButtonStyle.defaultStyle(ActionButtonType.submit), + ), + closeMenuButtonStyle: ActionButtonStyle.resolve( + style?.closeMenuButtonStyle, + defaultStyle: + ActionButtonStyle.defaultStyle(ActionButtonType.closeMenu), + ), + actionButtonBarDecoration: style?.actionButtonBarDecoration ?? + defaultStyle.actionButtonBarDecoration, + suggestionStyle: SuggestionStyle.resolve( + style?.suggestionStyle, + defaultStyle: defaultStyle.suggestionStyle, + ), + ); + } + + /// Provides default style if none is specified. + factory LlmChatViewStyle.defaultStyle() => LlmChatViewStyle._lightStyle(); + + /// Provides a default light style. + factory LlmChatViewStyle._lightStyle() => LlmChatViewStyle( + backgroundColor: ToolkitColors.containerBackground, + progressIndicatorColor: ToolkitColors.black, + userMessageStyle: UserMessageStyle.defaultStyle(), + llmMessageStyle: LlmMessageStyle.defaultStyle(), + chatInputStyle: ChatInputStyle.defaultStyle(), + addButtonStyle: ActionButtonStyle.defaultStyle(ActionButtonType.add), + stopButtonStyle: ActionButtonStyle.defaultStyle(ActionButtonType.stop), + recordButtonStyle: + ActionButtonStyle.defaultStyle(ActionButtonType.record), + submitButtonStyle: + ActionButtonStyle.defaultStyle(ActionButtonType.submit), + closeMenuButtonStyle: + ActionButtonStyle.defaultStyle(ActionButtonType.closeMenu), + attachFileButtonStyle: + ActionButtonStyle.defaultStyle(ActionButtonType.attachFile), + galleryButtonStyle: + ActionButtonStyle.defaultStyle(ActionButtonType.gallery), + cameraButtonStyle: + ActionButtonStyle.defaultStyle(ActionButtonType.camera), + closeButtonStyle: + ActionButtonStyle.defaultStyle(ActionButtonType.close), + cancelButtonStyle: + ActionButtonStyle.defaultStyle(ActionButtonType.cancel), + copyButtonStyle: ActionButtonStyle.defaultStyle(ActionButtonType.copy), + editButtonStyle: ActionButtonStyle.defaultStyle(ActionButtonType.edit), + actionButtonBarDecoration: BoxDecoration( + color: ToolkitColors.darkButtonBackground, + borderRadius: BorderRadius.circular(20), + ), + fileAttachmentStyle: FileAttachmentStyle.defaultStyle(), + suggestionStyle: SuggestionStyle.defaultStyle(), + ); + + /// Background color of the entire chat widget. + final Color? backgroundColor; + + /// The color of the progress indicator. + final Color? progressIndicatorColor; + + /// Style for user messages. + final UserMessageStyle? userMessageStyle; + + /// Style for LLM messages. + final LlmMessageStyle? llmMessageStyle; + + /// Style for the input text box. + final ChatInputStyle? chatInputStyle; + + /// Style for the add button. + final ActionButtonStyle? addButtonStyle; + + /// Style for the attach file button. + final ActionButtonStyle? attachFileButtonStyle; + + /// Style for the camera button. + final ActionButtonStyle? cameraButtonStyle; + + /// Style for the stop button. + final ActionButtonStyle? stopButtonStyle; + + /// Style for the close button. + final ActionButtonStyle? closeButtonStyle; + + /// Style for the cancel button. + final ActionButtonStyle? cancelButtonStyle; + + /// Style for the copy button. + final ActionButtonStyle? copyButtonStyle; + + /// Style for the edit button. + final ActionButtonStyle? editButtonStyle; + + /// Style for the gallery button. + final ActionButtonStyle? galleryButtonStyle; + + /// Style for the record button. + final ActionButtonStyle? recordButtonStyle; + + /// Style for the submit button. + final ActionButtonStyle? submitButtonStyle; + + /// Style for the close menu button. + final ActionButtonStyle? closeMenuButtonStyle; + + /// Decoration for the action button bar. + final Decoration? actionButtonBarDecoration; + + /// Style for file attachments. + final FileAttachmentStyle? fileAttachmentStyle; + + /// Style for suggestions. + final SuggestionStyle? suggestionStyle; +} diff --git a/lib/src/styles/llm_message_style.dart b/lib/src/styles/llm_message_style.dart new file mode 100644 index 0000000..a692a5f --- /dev/null +++ b/lib/src/styles/llm_message_style.dart @@ -0,0 +1,112 @@ +// Copyright 2024 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/widgets.dart'; +import 'package:flutter_markdown/flutter_markdown.dart'; + +import 'tookit_icons.dart'; +import 'toolkit_colors.dart'; +import 'toolkit_text_styles.dart'; + +/// Style for LLM messages. +@immutable +class LlmMessageStyle { + /// Creates an LlmMessageStyle. + const LlmMessageStyle({ + this.icon, + this.iconColor, + this.iconDecoration, + this.decoration, + this.markdownStyle, + }); + + /// Resolves the provided style with the default style. + /// + /// This method creates a new [LlmMessageStyle] by combining the provided + /// [style] with the [defaultStyle]. If a property is not specified in the + /// provided [style], it falls back to the corresponding property in the + /// [defaultStyle]. + /// + /// If [defaultStyle] is not provided, it uses [LlmMessageStyle.defaultStyle]. + /// + /// Parameters: + /// - [style]: The custom style to apply. Can be null. + /// - [defaultStyle]: The default style to use as a fallback. If null, uses + /// [LlmMessageStyle.defaultStyle]. + /// + /// Returns: A new [LlmMessageStyle] instance with resolved properties. + factory LlmMessageStyle.resolve( + LlmMessageStyle? style, { + LlmMessageStyle? defaultStyle, + }) { + defaultStyle ??= LlmMessageStyle.defaultStyle(); + return LlmMessageStyle( + icon: style?.icon ?? defaultStyle.icon, + iconColor: style?.iconColor ?? defaultStyle.iconColor, + iconDecoration: style?.iconDecoration ?? defaultStyle.iconDecoration, + markdownStyle: style?.markdownStyle ?? defaultStyle.markdownStyle, + decoration: style?.decoration ?? defaultStyle.decoration, + ); + } + + /// Provides a default style. + factory LlmMessageStyle.defaultStyle() => LlmMessageStyle._lightStyle(); + + /// Provides a default light style. + factory LlmMessageStyle._lightStyle() => LlmMessageStyle( + icon: ToolkitIcons.spark_icon, + iconColor: ToolkitColors.darkIcon, + iconDecoration: const BoxDecoration( + color: ToolkitColors.llmIconBackground, + shape: BoxShape.circle, + ), + markdownStyle: MarkdownStyleSheet( + a: ToolkitTextStyles.body1, + blockquote: ToolkitTextStyles.body1, + checkbox: ToolkitTextStyles.body1, + code: ToolkitTextStyles.code, + del: ToolkitTextStyles.body1, + em: ToolkitTextStyles.body1.copyWith(fontStyle: FontStyle.italic), + h1: ToolkitTextStyles.heading1, + h2: ToolkitTextStyles.heading2, + h3: ToolkitTextStyles.body1.copyWith(fontWeight: FontWeight.bold), + h4: ToolkitTextStyles.body1, + h5: ToolkitTextStyles.body1, + h6: ToolkitTextStyles.body1, + listBullet: ToolkitTextStyles.body1, + img: ToolkitTextStyles.body1, + strong: ToolkitTextStyles.body1.copyWith(fontWeight: FontWeight.bold), + p: ToolkitTextStyles.body1, + tableBody: ToolkitTextStyles.body1, + tableHead: ToolkitTextStyles.body1, + ), + decoration: BoxDecoration( + color: ToolkitColors.llmMessageBackground, + border: Border.all( + color: ToolkitColors.llmMessageOutline, + ), + borderRadius: const BorderRadius.only( + topLeft: Radius.zero, + topRight: Radius.circular(20), + bottomLeft: Radius.circular(20), + bottomRight: Radius.circular(20), + ), + ), + ); + + /// The icon to display for the LLM messages. + final IconData? icon; + + /// The color of the icon. + final Color? iconColor; + + /// The decoration for the icon. + final Decoration? iconDecoration; + + /// The decoration for LLM message bubbles. + final Decoration? decoration; + + /// The markdown style sheet for LLM messages. + final MarkdownStyleSheet? markdownStyle; +} diff --git a/lib/src/styles/styles.dart b/lib/src/styles/styles.dart new file mode 100644 index 0000000..b33b50c --- /dev/null +++ b/lib/src/styles/styles.dart @@ -0,0 +1,12 @@ +// Copyright 2024 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +export 'action_button_style.dart'; +export 'action_button_type.dart'; +export 'chat_input_style.dart'; +export 'file_attachment_style.dart'; +export 'llm_chat_view_style.dart'; +export 'llm_message_style.dart'; +export 'suggestion_style.dart'; +export 'user_message_style.dart'; diff --git a/lib/src/styles/suggestion_style.dart b/lib/src/styles/suggestion_style.dart new file mode 100644 index 0000000..6e4a2b4 --- /dev/null +++ b/lib/src/styles/suggestion_style.dart @@ -0,0 +1,60 @@ +// Copyright 2024 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/widgets.dart'; + +import 'toolkit_colors.dart'; +import 'toolkit_text_styles.dart'; + +/// A class that defines the style for suggestions. +@immutable +class SuggestionStyle { + /// Creates a [SuggestionStyle]. + /// + /// The [textStyle] and [decoration] parameters can be used to customize + /// the appearance of the suggestion. + const SuggestionStyle({ + this.textStyle, + this.decoration, + }); + + /// Resolves the [SuggestionStyle] by merging the provided [style] with the + /// [defaultStyle]. + /// + /// If [style] is null, the [defaultStyle] is used. If [defaultStyle] is not + /// provided, the [defaultStyle] is obtained from + /// [SuggestionStyle.defaultStyle]. + factory SuggestionStyle.resolve( + SuggestionStyle? style, { + SuggestionStyle? defaultStyle, + }) { + defaultStyle ??= SuggestionStyle.defaultStyle(); + return SuggestionStyle( + textStyle: style?.textStyle ?? defaultStyle.textStyle, + decoration: style?.decoration ?? defaultStyle.decoration, + ); + } + + /// Provides a default style. + /// + /// This style is typically used as the base style for suggestions. + factory SuggestionStyle.defaultStyle() => SuggestionStyle._lightStyle(); + + /// Provides a default light style. + /// + /// This style is typically used for suggestions in light mode. + factory SuggestionStyle._lightStyle() => SuggestionStyle( + textStyle: ToolkitTextStyles.body1, + decoration: const BoxDecoration( + color: ToolkitColors.userMessageBackground, + borderRadius: BorderRadius.all(Radius.circular(8)), + ), + ); + + /// The text style for the suggestion. + final TextStyle? textStyle; + + /// The decoration for the suggestion. + final Decoration? decoration; +} diff --git a/lib/src/styles/tookit_icons.dart b/lib/src/styles/tookit_icons.dart new file mode 100644 index 0000000..43d6ca2 --- /dev/null +++ b/lib/src/styles/tookit_icons.dart @@ -0,0 +1,70 @@ +// Copyright 2024 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// by convention, using the names of the icons as the constant names +// ignore_for_file: constant_identifier_names + +import 'package:flutter/widgets.dart'; + +/// A collection of custom icons used in the Fat application. +/// +/// This class provides a set of static [IconData] constants that can be used +/// to display custom icons in the application. These icons are part of a custom +/// font called 'FatIcons'. +/// Material Design Icons, Copyright (C) Google, Inc +/// Author: Google +/// License: Apache 2.0 (https://www.apache.org/licenses/LICENSE-2.0) +/// Homepage: https://design.google.com/icons/ +/// +@immutable +class ToolkitIcons { + const ToolkitIcons._(); + + static const _kFontFam = 'FatIcons'; + static const String _kFontPkg = 'flutter_ai_toolkit'; + + /// Icon for submitting or sending. + static const IconData submit_icon = + IconData(0xe800, fontFamily: _kFontFam, fontPackage: _kFontPkg); + + /// Icon representing a spark or idea. + static const IconData spark_icon = + IconData(0xe801, fontFamily: _kFontFam, fontPackage: _kFontPkg); + + /// Icon for adding or creating new items. + static const IconData add = + IconData(0xe802, fontFamily: _kFontFam, fontPackage: _kFontPkg); + + /// Icon for attaching files. + static const IconData attach_file = + IconData(0xe803, fontFamily: _kFontFam, fontPackage: _kFontPkg); + + /// Icon for stopping or halting an action. + static const IconData stop = + IconData(0xe804, fontFamily: _kFontFam, fontPackage: _kFontPkg); + + /// Icon representing a microphone. + static const IconData mic = + IconData(0xe805, fontFamily: _kFontFam, fontPackage: _kFontPkg); + + /// Icon for closing or dismissing. + static const IconData close = + IconData(0xe806, fontFamily: _kFontFam, fontPackage: _kFontPkg); + + /// Icon representing a camera. + static const IconData camera_alt = + IconData(0xe807, fontFamily: _kFontFam, fontPackage: _kFontPkg); + + /// Icon representing an image or picture. + static const IconData image = + IconData(0xe808, fontFamily: _kFontFam, fontPackage: _kFontPkg); + + /// Icon for editing. + static const IconData edit = + IconData(0xe809, fontFamily: _kFontFam, fontPackage: _kFontPkg); + + /// Icon for copying content. + static const IconData content_copy = + IconData(0xe80a, fontFamily: _kFontFam, fontPackage: _kFontPkg); +} diff --git a/lib/src/styles/toolkit_colors.dart b/lib/src/styles/toolkit_colors.dart new file mode 100644 index 0000000..411ec68 --- /dev/null +++ b/lib/src/styles/toolkit_colors.dart @@ -0,0 +1,101 @@ +// Copyright 2024 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/widgets.dart'; + +/// A collection of color constants used throughout the application. +@immutable +abstract final class ToolkitColors { + /// Fully transparent color. + static const Color transparent = Color(0x00000000); + + /// Pure black color. + static const Color black = Color(0xFF000000); + + /// Pure red color. + static const Color red = Color(0xFFFF0000); + // Color 0 (#FFFFFF) + /// White color used for button backgrounds. + static const Color whiteButtonBackground = Color(0xFFFFFFFF); + + /// White color used for container backgrounds. + static const Color containerBackground = Color(0xFFFFFFFF); + + /// White color used for LLM message backgrounds. + static const Color llmMessageBackground = Color(0xFFFFFFFF); + + /// White color used for LLM message outlines. + static const Color llmMessageOutline = Color(0xFFFFFFFF); + + /// White color used for icons. + static const Color whiteIcon = Color(0xFFFFFFFF); + + /// White color used for tooltip text. + static const Color tooltipText = Color(0xFFFFFFFF); + + // Color 100 (#F5F5F5) + /// Light gray color used for user message backgrounds. + static const Color userMessageBackground = Color(0xFFF5F5F5); + + /// Light gray color used for file container backgrounds. + static const Color fileContainerBackground = Color(0xFFF5F5F5); + + /// Light gray color used for light button backgrounds. + static const Color lightButtonBackground = Color(0xFFF5F5F5); + + /// Light gray color used for dark button icons. + static const Color darkButtonIcon = Color(0xFFF5F5F5); + + // Color 200 (#E5E5E5) + /// Light gray color used for outlines. + static const Color outline = Color(0xFFE5E5E5); + + /// Light gray color used for LLM icon backgrounds. + static const Color llmIconBackground = Color(0xFFE5E5E5); + + /// Light gray color used for disabled buttons. + static const Color disabledButton = Color(0xFFE5E5E5); + + // Color 300 (#CACACA) + /// Gray color used for hint text. + static const Color hintText = Color(0xFFCACACA); + + /// Gray color used for file attachment icon backgrounds. + static const Color fileAttachmentIconBackground = Color(0xFFCACACA); + + /// Gray color used for grey button backgrounds. + static const Color greyButtonBackground = Color(0xFFCACACA); + + /// Gray color used for light icons. + static const Color lightIcon = Color(0xFFCACACA); + + /// Gray color used for image placeholders. + static const Color imagePlaceholder = Color(0xFFCACACA); + + /// Gray color used for light pagination circles. + static const Color lightPaginationCircle = Color(0xFFCACACA); + + /// Gray color used for light voice bar lines. + static const Color lightVoiceBarLine = Color(0xFFCACACA); + + // Color 400 (#535353) + /// Dark gray color used for grey backgrounds. + static const Color greyBackground = Color(0xFF535353); + + /// Dark gray color used for LLM name text. + static const Color llmNameText = Color(0xFF535353); + + /// Dark gray color used for tooltip backgrounds. + static const Color tooltipBackground = Color(0xFF535353); + + // Color 500 (#2F2F2F) + /// Very dark gray color used for dark button backgrounds. + static const Color darkButtonBackground = Color(0xFF2F2F2F); + + /// Very dark gray color used for dark icons. + static const Color darkIcon = Color(0xFF2F2F2F); + + /// Very dark gray color used for enabled text. + static const Color enabledText = Color(0xFF2F2F2F); +} diff --git a/lib/src/styles/toolkit_text_styles.dart b/lib/src/styles/toolkit_text_styles.dart new file mode 100644 index 0000000..56c3466 --- /dev/null +++ b/lib/src/styles/toolkit_text_styles.dart @@ -0,0 +1,102 @@ +// Copyright 2024 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/widgets.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import 'toolkit_colors.dart'; + +/// A utility class that defines text styles for the Fat design system. +@immutable +abstract final class ToolkitTextStyles { + /// Large display text style. + /// + /// Used for the most prominent text elements, typically headers or titles. + static final TextStyle display = GoogleFonts.roboto( + color: ToolkitColors.enabledText, + fontSize: 32, + fontWeight: FontWeight.w400, + ); + + /// Primary heading text style. + /// + /// Used for main section headings or important subheadings. + static final TextStyle heading1 = GoogleFonts.roboto( + color: ToolkitColors.enabledText, + fontSize: 24, + fontWeight: FontWeight.w400, + ); + + /// Secondary heading text style. + /// + /// Used for subsection headings or less prominent titles. + static final TextStyle heading2 = GoogleFonts.roboto( + color: ToolkitColors.enabledText, + fontSize: 20, + fontWeight: FontWeight.w400, + ); + + /// Primary body text style. + /// + /// Used for the main content text in the application. + static final TextStyle body1 = GoogleFonts.roboto( + color: ToolkitColors.enabledText, + fontSize: 16, + fontWeight: FontWeight.w400, + ); + + /// Code text style. + /// + /// Used for displaying code snippets or monospaced text. + static final TextStyle code = GoogleFonts.robotoMono( + color: ToolkitColors.enabledText, + fontSize: 16, + fontWeight: FontWeight.w400, + ); + + /// Secondary body text style. + /// + /// Used for less prominent body text or supporting information. + static final TextStyle body2 = GoogleFonts.roboto( + color: ToolkitColors.enabledText, + fontSize: 14, + fontWeight: FontWeight.w400, + ); + + /// Tooltip text style. + /// + /// Used for the text of tooltips. + static final TextStyle tooltip = GoogleFonts.roboto( + color: ToolkitColors.tooltipText.withOpacity(0.9), + fontSize: 14, + fontWeight: FontWeight.w400, + ); + + /// Filename text style. + /// + /// Used for the text of file attachments. + static final TextStyle filename = GoogleFonts.roboto( + color: ToolkitColors.enabledText, + fontSize: 14, + fontWeight: FontWeight.w400, + ); + + /// File type text style. + /// + /// Used for displaying the file type or MIME type of attachments. + static final TextStyle filetype = GoogleFonts.roboto( + color: ToolkitColors.hintText, + fontSize: 14, + fontWeight: FontWeight.w400, + ); + + /// Label text style. + /// + /// Used for small labels, captions, or helper text. + static final TextStyle label = GoogleFonts.roboto( + color: ToolkitColors.enabledText, + fontSize: 12, + fontWeight: FontWeight.w400, + ); +} diff --git a/lib/src/styles/user_message_style.dart b/lib/src/styles/user_message_style.dart new file mode 100644 index 0000000..37818d8 --- /dev/null +++ b/lib/src/styles/user_message_style.dart @@ -0,0 +1,64 @@ +// Copyright 2024 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/widgets.dart'; + +import 'toolkit_colors.dart'; +import 'toolkit_text_styles.dart'; + +/// Style for user messages. +@immutable +class UserMessageStyle { + /// Creates a UserMessageStyle. + const UserMessageStyle({ + this.textStyle, + this.decoration, + }); + + /// Resolves the UserMessageStyle by combining the provided style with default + /// values. + /// + /// This method takes an optional [style] and merges it with the + /// [defaultStyle]. If [defaultStyle] is not provided, it uses + /// [UserMessageStyle.defaultStyle]. + /// + /// [style] - The custom UserMessageStyle to apply. Can be null. + /// [defaultStyle] - The default UserMessageStyle to use as a base. If null, + /// uses [UserMessageStyle.defaultStyle]. + /// + /// Returns a new [UserMessageStyle] instance with resolved properties. + factory UserMessageStyle.resolve( + UserMessageStyle? style, { + UserMessageStyle? defaultStyle, + }) { + defaultStyle ??= UserMessageStyle.defaultStyle(); + return UserMessageStyle( + textStyle: style?.textStyle ?? defaultStyle.textStyle, + decoration: style?.decoration ?? defaultStyle.decoration, + ); + } + + /// Provides default style data for user messages. + factory UserMessageStyle.defaultStyle() => UserMessageStyle._lightStyle(); + + /// Provides a default light style. + factory UserMessageStyle._lightStyle() => UserMessageStyle( + textStyle: ToolkitTextStyles.body1, + decoration: const BoxDecoration( + color: ToolkitColors.userMessageBackground, + borderRadius: BorderRadius.only( + topLeft: Radius.circular(20), + topRight: Radius.zero, + bottomLeft: Radius.circular(20), + bottomRight: Radius.circular(20), + ), + ), + ); + + /// The text style for user messages. + final TextStyle? textStyle; + + /// The decoration for user message bubbles. + final Decoration? decoration; +} diff --git a/lib/src/utility.dart b/lib/src/utility.dart new file mode 100644 index 0000000..6418332 --- /dev/null +++ b/lib/src/utility.dart @@ -0,0 +1,74 @@ +// Copyright 2024 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/cupertino.dart' show BuildContext, CupertinoApp; +import 'package:flutter/services.dart'; +import 'package:universal_platform/universal_platform.dart'; + +import 'dialogs/adaptive_snack_bar/adaptive_snack_bar.dart'; + +bool? _isCupertinoApp; + +/// Determines if the current application is a Cupertino-style app. +/// +/// This function checks the widget tree for the presence of a [CupertinoApp] +/// widget. If found, it indicates that the app is using Cupertino (iOS-style) +/// widgets. +/// +/// Parameters: +/// * [context]: The [BuildContext] used to search the widget tree. +/// +/// Returns: A [bool] value. `true` if a [CupertinoApp] is found in the widget +/// tree, `false` otherwise. +bool isCupertinoApp(BuildContext context) { + // caching the result to avoid recomputing it on every call; it's not likely + // to change during the lifetime of the app + _isCupertinoApp ??= + context.findAncestorWidgetOfExactType() != null; + return _isCupertinoApp!; +} + +/// Determines if the current platform is a mobile device (Android or iOS). +/// +/// This constant uses the [UniversalPlatform] package to check the platform. +/// +/// Returns: +/// A [bool] value. `true` if the platform is either Android or iOS, +/// `false` otherwise. +final isMobile = UniversalPlatform.isAndroid || UniversalPlatform.isIOS; + +/// Copies the given text to the clipboard and shows a confirmation message. +/// +/// This function uses the [Clipboard] API to copy the provided [text] to the +/// system clipboard. After copying, it displays a confirmation message using +/// [AdaptiveSnackBar] if the [context] is still mounted. +/// +/// Parameters: +/// * [context]: The [BuildContext] used to show the confirmation message. +/// * [text]: The text to be copied to the clipboard. +/// +/// Returns: A [Future] that completes when the text has been copied to the +/// clipboard and the confirmation message has been shown. +Future copyToClipboard(BuildContext context, String text) async { + await Clipboard.setData(ClipboardData(text: text)); + if (context.mounted) { + AdaptiveSnackBar.show(context, 'Message copied to clipboard'); + } +} + +/// Inverts the given color. +/// +/// This function takes a [Color] object and returns a new [Color] object +/// with the RGB values inverted. The alpha value remains unchanged. +/// +/// Parameters: +/// * [color]: The [Color] to be inverted. This parameter must not be null. +/// +/// Returns: A new [Color] object with the inverted RGB values. +Color invertColor(Color? color) => Color.fromARGB( + color!.alpha, + 255 - color.red, + 255 - color.green, + 255 - color.blue, + ); diff --git a/lib/src/views/action_button/action_button.dart b/lib/src/views/action_button/action_button.dart new file mode 100644 index 0000000..2f8802b --- /dev/null +++ b/lib/src/views/action_button/action_button.dart @@ -0,0 +1,65 @@ +// Copyright 2024 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart' show Tooltip; +import 'package:flutter/widgets.dart'; + +import '../../styles/action_button_style.dart'; +import '../../utility.dart'; + +/// A button widget with an icon. +/// +/// This widget creates a button with a customizable icon, size, decoration, and +/// color. It can be enabled or disabled based on the presence of an [onPressed] +/// callback. +@immutable +class ActionButton extends StatelessWidget { + /// Creates an [ActionButton]. + /// + /// The [onPressed] and [style] parameters must not be null. + /// The [size] parameter defaults to 40 if not provided. + const ActionButton({ + required this.onPressed, + required this.style, + super.key, + this.size = 40, + }); + + /// The callback that is called when the button is tapped. + /// If null, the button will be disabled. + final VoidCallback onPressed; + + /// The style of the button. + final ActionButtonStyle style; + + /// The diameter of the circular button. + final double size; + + @override + Widget build(BuildContext context) => GestureDetector( + onTap: onPressed, + child: Container( + width: size, + height: size, + decoration: style.iconDecoration, + // tooltips aren't a thing in cupertino, so skip it + child: isCupertinoApp(context) + ? Icon( + style.icon, + color: style.iconColor, + size: size * 0.6, + ) + : Tooltip( + message: style.tooltip, + textStyle: style.tooltipTextStyle, + decoration: style.tooltipDecoration, + child: Icon( + style.icon, + color: style.iconColor, + size: size * 0.6, + ), + ), + ), + ); +} diff --git a/lib/src/views/action_button/action_button_bar.dart b/lib/src/views/action_button/action_button_bar.dart new file mode 100644 index 0000000..1940096 --- /dev/null +++ b/lib/src/views/action_button/action_button_bar.dart @@ -0,0 +1,40 @@ +// Copyright 2024 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/widgets.dart'; + +import '../../styles/styles.dart'; +import 'action_button.dart'; + +/// A widget that displays a horizontal bar of [ActionButton]s. +/// +/// This widget creates a container with rounded corners that houses a series of +/// [ActionButton]s. The buttons are laid out horizontally and can overflow if +/// there's not enough space. +@immutable +class ActionButtonBar extends StatelessWidget { + /// Creates a [ActionButtonBar]. + /// + /// The [buttons] parameter is required and specifies the list of + /// [ActionButton]s to be displayed in the bar. + const ActionButtonBar( + this.buttons, { + required this.style, + super.key, + }); + + /// The list of [ActionButton]s to be displayed in the bar. + final List buttons; + + /// The style of the action button bar. + final LlmChatViewStyle style; + + @override + Widget build(BuildContext context) => DecoratedBox( + decoration: style.actionButtonBarDecoration!, + child: OverflowBar( + children: buttons, + ), + ); +} diff --git a/lib/src/views/adaptive_progress_indicator.dart b/lib/src/views/adaptive_progress_indicator.dart new file mode 100644 index 0000000..34db757 --- /dev/null +++ b/lib/src/views/adaptive_progress_indicator.dart @@ -0,0 +1,31 @@ +// Copyright 2024 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/cupertino.dart' show CupertinoActivityIndicator; +import 'package:flutter/material.dart' show CircularProgressIndicator; +import 'package:flutter/widgets.dart'; + +import '../utility.dart'; + +/// A progress indicator that adapts to the current platform. +/// +@immutable +class AdaptiveCircularProgressIndicator extends StatelessWidget { + /// Creates an adaptive circular progress indicator. + /// + /// This widget will display a [CupertinoActivityIndicator] on iOS + /// and a [CircularProgressIndicator] on other platforms. + /// + /// The [key] parameter is optional and is used to control how one widget + /// replaces another widget in the tree. + const AdaptiveCircularProgressIndicator({required this.color, super.key}); + + /// The color of the progress indicator. + final Color color; + + @override + Widget build(BuildContext context) => isCupertinoApp(context) + ? CupertinoActivityIndicator(color: color) + : CircularProgressIndicator(color: color); +} diff --git a/lib/src/views/attachment_view/attachment_view.dart b/lib/src/views/attachment_view/attachment_view.dart new file mode 100644 index 0000000..fa4fbe1 --- /dev/null +++ b/lib/src/views/attachment_view/attachment_view.dart @@ -0,0 +1,34 @@ +// Copyright 2024 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/widgets.dart'; + +import '../../providers/interface/attachments.dart'; +import 'file_attatchment_view.dart'; +import 'image_attachment_view.dart'; + +/// A widget that displays an attachment based on its type. +/// +/// This widget determines the appropriate view for the given [attachment] +/// and renders it accordingly. It supports file attachments and image +/// attachments, but throws an exception for link attachments. +@immutable +class AttachmentView extends StatelessWidget { + /// Creates an AttachmentView. + /// + /// The [attachment] parameter must not be null. + const AttachmentView(this.attachment, {super.key}); + + /// The attachment to be displayed. + final Attachment attachment; + + /// The style for the attachment view. + + @override + Widget build(BuildContext context) => switch (attachment) { + (final ImageFileAttachment a) => ImageAttachmentView(a), + (final FileAttachment a) => FileAttachmentView(a), + (LinkAttachment _) => throw Exception('Link attachments not supported'), + }; +} diff --git a/lib/src/views/attachment_view/file_attatchment_view.dart b/lib/src/views/attachment_view/file_attatchment_view.dart new file mode 100644 index 0000000..3423250 --- /dev/null +++ b/lib/src/views/attachment_view/file_attatchment_view.dart @@ -0,0 +1,75 @@ +// Copyright 2024 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/widgets.dart'; +import 'package:gap/gap.dart'; + +import '../../chat_view_model/chat_view_model_client.dart'; +import '../../providers/interface/attachments.dart'; +import '../../styles/file_attachment_style.dart'; + +/// A widget that displays a file attachment. +/// +/// This widget creates a container with a file icon and information about the +/// attached file, such as its name and MIME type. +@immutable +class FileAttachmentView extends StatelessWidget { + /// Creates a FileAttachmentView. + /// + /// The [attachment] parameter must not be null and represents the + /// file attachment to be displayed. + const FileAttachmentView(this.attachment, {super.key}); + + /// The file attachment to be displayed. + final FileAttachment attachment; + + @override + Widget build(BuildContext context) => ChatViewModelClient( + builder: (context, viewModel, child) { + final attachmentStyle = FileAttachmentStyle.resolve( + viewModel.style?.fileAttachmentStyle, + ); + + return Container( + height: 80, + padding: const EdgeInsets.all(8), + decoration: attachmentStyle.decoration, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + height: 64, + padding: const EdgeInsets.all(10), + decoration: attachmentStyle.iconDecoration, + child: Icon( + attachmentStyle.icon, + color: attachmentStyle.iconColor, + size: 24, + ), + ), + const Gap(8), + Flexible( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + attachment.name, + style: attachmentStyle.filenameStyle, + overflow: TextOverflow.ellipsis, + ), + Text( + attachment.mimeType, + style: attachmentStyle.filetypeStyle, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ], + ), + ); + }, + ); +} diff --git a/lib/src/views/attachment_view/image_attachment_view.dart b/lib/src/views/attachment_view/image_attachment_view.dart new file mode 100644 index 0000000..d755948 --- /dev/null +++ b/lib/src/views/attachment_view/image_attachment_view.dart @@ -0,0 +1,42 @@ +// Copyright 2024 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; + +import 'package:flutter/widgets.dart'; + +import '../../dialogs/adaptive_dialog.dart'; +import '../../dialogs/image_preview_dialog.dart'; +import '../../providers/interface/attachments.dart'; + +/// A widget that displays an image attachment. +/// +/// This widget aligns the image to the center-right of its parent and +/// allows the user to tap on the image to open a preview dialog. +@immutable +class ImageAttachmentView extends StatelessWidget { + /// Creates an ImageAttachmentView. + /// + /// The [attachment] parameter must not be null and represents the + /// image file attachment to be displayed. + const ImageAttachmentView(this.attachment, {super.key}); + + /// The image file attachment to be displayed. + final ImageFileAttachment attachment; + + @override + Widget build(BuildContext context) => Align( + alignment: Alignment.centerRight, + child: GestureDetector( + onTap: () => unawaited(_showPreviewDialog(context)), + child: Image.memory(attachment.bytes)), + ); + + Future _showPreviewDialog(BuildContext context) async => + AdaptiveAlertDialog.show( + context: context, + barrierDismissible: true, + content: ImagePreviewDialog(attachment), + ); +} diff --git a/lib/src/views/chat_history_view.dart b/lib/src/views/chat_history_view.dart new file mode 100644 index 0000000..3e68522 --- /dev/null +++ b/lib/src/views/chat_history_view.dart @@ -0,0 +1,89 @@ +// Copyright 2024 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/widgets.dart'; + +import '../chat_view_model/chat_view_model_client.dart'; +import '../providers/interface/chat_message.dart'; +import '../providers/interface/message_origin.dart'; +import 'chat_message_view/llm_message_view.dart'; +import 'chat_message_view/user_message_view.dart'; + +/// A widget that displays a history of chat messages. +/// +/// This widget renders a scrollable list of chat messages, supporting +/// selection and editing of messages. It displays messages in reverse +/// chronological order (newest at the bottom). +@immutable +class ChatHistoryView extends StatefulWidget { + /// Creates a [ChatHistoryView]. + /// + /// If [onEditMessage] is provided, it will be called when a user initiates an + /// edit action on an editable message (typically the last user message in the + /// history). + const ChatHistoryView({ + this.onEditMessage, + super.key, + }); + + /// Optional callback function for editing a message. + /// + /// If provided, this function will be called when a user initiates an edit + /// action on an editable message (typically the last user message in the + /// history). The function receives the [ChatMessage] to be edited as its + /// parameter. + final void Function(ChatMessage message)? onEditMessage; + + @override + State createState() => _ChatHistoryViewState(); +} + +class _ChatHistoryViewState extends State { + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.only(top: 16, left: 16, right: 16), + child: ChatViewModelClient( + builder: (context, viewModel, child) { + final history = [ + if (viewModel.welcomeMessage != null) + ChatMessage( + origin: MessageOrigin.llm, + text: viewModel.welcomeMessage, + attachments: [], + ), + ...viewModel.provider.history, + ]; + + return ListView.builder( + reverse: true, + itemCount: history.length, + itemBuilder: (context, index) { + final messageIndex = history.length - index - 1; + final message = history[messageIndex]; + final isLastUserMessage = + message.origin.isUser && messageIndex >= history.length - 2; + final canEdit = + isLastUserMessage && widget.onEditMessage != null; + final isUser = message.origin.isUser; + + return Padding( + padding: const EdgeInsets.only(top: 6), + child: isUser + ? UserMessageView( + message, + onEdit: canEdit + ? () => widget.onEditMessage?.call(message) + : null, + ) + : LlmMessageView( + message, + isWelcomeMessage: messageIndex == 0, + ), + ); + }, + ); + }, + ), + ); +} diff --git a/lib/src/views/chat_input/attachments_action_bar.dart b/lib/src/views/chat_input/attachments_action_bar.dart new file mode 100644 index 0000000..995ca91 --- /dev/null +++ b/lib/src/views/chat_input/attachments_action_bar.dart @@ -0,0 +1,127 @@ +// Copyright 2024 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; + +import 'package:file_selector/file_selector.dart'; +import 'package:flutter/widgets.dart'; +import 'package:image_picker/image_picker.dart'; + +import '../../chat_view_model/chat_view_model_client.dart'; +import '../../dialogs/adaptive_snack_bar/adaptive_snack_bar.dart'; +import '../../platform_helper/platform_helper.dart'; +import '../../providers/interface/attachments.dart'; +import '../../styles/llm_chat_view_style.dart'; +import '../action_button/action_button.dart'; +import '../action_button/action_button_bar.dart'; + +/// A widget that provides an action bar for attaching files or images. +@immutable +class AttachmentActionBar extends StatefulWidget { + /// Creates an [AttachmentActionBar]. + /// + /// The [onAttachments] parameter is required and is called when attachments + /// are selected. + const AttachmentActionBar({required this.onAttachments, super.key}); + + /// Callback function that is called when attachments are selected. + /// + /// The selected [Attachment]s are passed as an argument to this function. + final Function(Iterable attachments) onAttachments; + + @override + State createState() => _AttachmentActionBarState(); +} + +class _AttachmentActionBarState extends State { + var _expanded = false; + late final bool _canCamera; + + @override + void initState() { + super.initState(); + _canCamera = canTakePhoto(); + } + + @override + Widget build(BuildContext context) => ChatViewModelClient( + builder: (context, viewModel, child) { + final chatStyle = LlmChatViewStyle.resolve(viewModel.style); + return _expanded + ? ActionButtonBar(style: chatStyle, [ + ActionButton( + onPressed: _onToggleMenu, + style: chatStyle.closeMenuButtonStyle!, + ), + if (_canCamera) + ActionButton( + onPressed: _onCamera, + style: chatStyle.cameraButtonStyle!, + ), + ActionButton( + onPressed: _onGallery, + style: chatStyle.galleryButtonStyle!, + ), + ActionButton( + onPressed: _onFile, + style: chatStyle.attachFileButtonStyle!, + ), + ]) + : ActionButton( + onPressed: _onToggleMenu, + style: chatStyle.addButtonStyle!, + ); + }, + ); + + void _onToggleMenu() => setState(() => _expanded = !_expanded); + void _onCamera() => unawaited(_pickImage(ImageSource.camera)); + void _onGallery() => unawaited(_pickImage(ImageSource.gallery)); + + Future _pickImage(ImageSource source) async { + _onToggleMenu(); // close the menu + + assert( + source == ImageSource.camera || source == ImageSource.gallery, + 'Unsupported image source: $source', + ); + + final picker = ImagePicker(); + try { + if (source == ImageSource.gallery) { + final pics = await picker.pickMultiImage(); + final attachments = await Future.wait(pics.map( + ImageFileAttachment.fromFile, + )); + widget.onAttachments(attachments); + } else { + final pic = await takePhoto(context); + if (pic == null) return; + widget.onAttachments([await ImageFileAttachment.fromFile(pic)]); + } + } on Exception catch (ex) { + if (context.mounted) { + // I just checked this! ^^^ + // ignore: use_build_context_synchronously + AdaptiveSnackBar.show(context, 'Unable to pick an image: $ex'); + } + } + } + + Future _onFile() async { + _onToggleMenu(); // close the menu + + try { + final files = await openFiles(); + final attachments = await Future.wait(files.map(FileAttachment.fromFile)); + widget.onAttachments(attachments); + } on Exception catch (ex) { + if (context.mounted) { + // I just checked this! ^^^ + // ignore: use_build_context_synchronously + AdaptiveSnackBar.show(context, 'Unable to pick a file: $ex'); + } + } + } +} diff --git a/lib/src/views/chat_input/attachments_view.dart b/lib/src/views/chat_input/attachments_view.dart new file mode 100644 index 0000000..58bf60d --- /dev/null +++ b/lib/src/views/chat_input/attachments_view.dart @@ -0,0 +1,47 @@ +// Copyright 2024 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/widgets.dart'; + +import '../../providers/interface/attachments.dart'; +import 'removable_attachment.dart'; + +/// A widget that displays a horizontal list of attachments with the ability to +/// remove them. +@immutable +class AttachmentsView extends StatelessWidget { + /// Creates an [AttachmentsView]. + /// + /// The [attachments] parameter is required and represents the list of + /// attachments to display. The [onRemove] parameter is a callback function + /// that is called when an attachment is removed. + const AttachmentsView({ + required this.attachments, + required this.onRemove, + super.key, + }); + + /// The list of attachments to display. + final Iterable attachments; + + /// Callback function that is called when an attachment is removed. + /// + /// The removed [Attachment] is passed as an argument to this function. + final Function(Attachment) onRemove; + + @override + Widget build(BuildContext context) => Container( + height: attachments.isNotEmpty ? 104 : 0, + padding: const EdgeInsets.only(top: 12, bottom: 12, left: 12), + child: attachments.isNotEmpty + ? ListView( + scrollDirection: Axis.horizontal, + children: [ + for (final a in attachments) + RemovableAttachment(attachment: a, onRemove: onRemove), + ], + ) + : const SizedBox(), + ); +} diff --git a/lib/src/views/chat_input/chat_input.dart b/lib/src/views/chat_input/chat_input.dart new file mode 100644 index 0000000..9e46170 --- /dev/null +++ b/lib/src/views/chat_input/chat_input.dart @@ -0,0 +1,304 @@ +// Copyright 2024 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:file_selector/file_selector.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:gap/gap.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:waveform_recorder/waveform_recorder.dart'; + +import '../../chat_view_model/chat_view_model_client.dart'; +import '../../dialogs/adaptive_snack_bar/adaptive_snack_bar.dart'; +import '../../providers/interface/attachments.dart'; +import '../../providers/interface/chat_message.dart'; +import '../../styles/chat_input_style.dart'; +import '../../styles/llm_chat_view_style.dart'; +import '../../utility.dart'; +import '../chat_text_field.dart'; +import 'attachments_action_bar.dart'; +import 'attachments_view.dart'; +import 'editing_indicator.dart'; +import 'input_button.dart'; +import 'input_state.dart'; + +/// A widget that provides a chat input interface with support for text input, +/// speech-to-text, and attachments. +@immutable +class ChatInput extends StatefulWidget { + /// Creates a [ChatInput] widget. + /// + /// The [onSendMessage] and [onTranslateStt] parameters are required. + /// + /// [initialMessage] can be provided to pre-populate the input field. + /// + /// [onCancelMessage] and [onCancelStt] are optional callbacks for cancelling + /// message submission or speech-to-text translation respectively. + const ChatInput({ + required this.onSendMessage, + required this.onTranslateStt, + this.initialMessage, + this.onCancelEdit, + this.onCancelMessage, + this.onCancelStt, + super.key, + }) : assert( + !(onCancelMessage != null && onCancelStt != null), + 'Cannot be submitting a prompt and doing stt at the same time', + ), + assert( + !(onCancelEdit != null && initialMessage == null), + 'Cannot cancel edit of a message if no initial message is provided', + ); + + /// Callback function triggered when a message is sent. + /// + /// Takes a [String] for the message text and an [`Iterable`] for + /// any attachments. + final void Function(String, Iterable) onSendMessage; + + /// Callback function triggered when speech-to-text translation is requested. + /// + /// Takes an [XFile] representing the audio file to be translated. + final void Function(XFile file) onTranslateStt; + + /// The initial message to populate the input field, if any. + final ChatMessage? initialMessage; + + /// Optional callback function to cancel an ongoing edit of a message, passed + /// via [initialMessage], that has already received a response. To allow for a + /// non-destructive edit, if the user cancels the editing of the message, we + /// call [onCancelEdit] to revert to the original message and response. + final void Function()? onCancelEdit; + + /// Optional callback function to cancel an ongoing message submission. + final void Function()? onCancelMessage; + + /// Optional callback function to cancel an ongoing speech-to-text + /// translation. + final void Function()? onCancelStt; + + @override + State createState() => _ChatInputState(); +} + +class _ChatInputState extends State { + // Notes on the way focus works in this widget: + // - we use a focus node to request focus when the input is submitted or + // cancelled + // - we leave the text field enabled so that it never artifically loses focus + // (you can't have focus on a disabled widget) + // - this means we're not taking back focus after a submission or a + // cancellation is complete from another widget in the app that might have + // it, e.g. if we attempted to take back focus in didUpdateWidget + // - this also means that we don't need any complicated logic to request focus + // in didUpdateWidget only the first time after a submission or cancellation + // that would be required to keep from stealing focus from other widgets in + // the app + // - also, if the user is submitting and they press Enter while inside the + // text field, we want to put the focus back in the text field but otherwise + // ignore the Enter key; it doesn't make sense for Enter to cancel - they + // can use the Cancel button for that. + // - the reason we need to request focus in the onSubmitted function of the + // TextField is because apparently it gives up focus as part of its + // implementation somehow (just how is something to discover) + // - the reason we need to request focus in the implementation of the separate + // submit/cancel button is because clicking on another widget when the + // TextField is focused causes it to lose focus (as it should) + final _focusNode = FocusNode(); + + final _textController = TextEditingController(); + final _waveController = WaveformRecorderController(); + final _attachments = []; + static const _minInputHeight = 48.0; + static const _maxInputHeight = 144.0; + + @override + void didUpdateWidget(covariant ChatInput oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.initialMessage != null) { + _textController.text = widget.initialMessage!.text ?? ''; + _attachments.clear(); + _attachments.addAll(widget.initialMessage!.attachments); + } + } + + @override + void dispose() { + _textController.dispose(); + _waveController.dispose(); + _focusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => ChatViewModelClient( + builder: (context, viewModel, child) { + final chatStyle = LlmChatViewStyle.resolve(viewModel.style); + final inputStyle = ChatInputStyle.resolve( + viewModel.style?.chatInputStyle, + ); + + return Container( + color: inputStyle.backgroundColor, + padding: const EdgeInsets.all(16), + child: Column( + children: [ + AttachmentsView( + attachments: _attachments, + onRemove: onRemoveAttachment, + ), + if (_attachments.isNotEmpty) const Gap(6), + ValueListenableBuilder( + valueListenable: _textController, + builder: (context, value, child) => ListenableBuilder( + listenable: _waveController, + builder: (context, child) => Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Padding( + padding: const EdgeInsets.only(bottom: 14), + child: AttachmentActionBar( + onAttachments: onAttachments, + ), + ), + Expanded( + child: Stack( + children: [ + Padding( + padding: EdgeInsets.only( + left: 16, + right: 16, + top: widget.onCancelEdit != null ? 24 : 8, + bottom: 8, + ), + child: DecoratedBox( + decoration: inputStyle.decoration!, + child: ConstrainedBox( + constraints: const BoxConstraints( + minHeight: _minInputHeight, + maxHeight: _maxInputHeight, + ), + child: _waveController.isRecording + ? WaveformRecorder( + controller: _waveController, + height: _minInputHeight, + onRecordingStopped: + onRecordingStopped, + ) + : SingleChildScrollView( + child: ChatTextField( + minLines: 1, + maxLines: 1024, + controller: _textController, + autofocus: true, + focusNode: _focusNode, + textInputAction: isMobile + ? TextInputAction.newline + : TextInputAction.done, + onSubmitted: _inputState == + InputState.canSubmitPrompt + ? (_) => onSubmitPrompt() + : (_) => + _focusNode.requestFocus(), + style: inputStyle.textStyle!, + hintText: inputStyle.hintText!, + hintStyle: inputStyle.hintStyle!, + hintPadding: + const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + ), + ), + ), + ), + ), + Align( + alignment: Alignment.topRight, + child: widget.onCancelEdit != null + ? EditingIndicator( + onCancelEdit: widget.onCancelEdit!, + cancelButtonStyle: + chatStyle.cancelButtonStyle!, + ) + : const SizedBox(), + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.only(bottom: 14), + child: InputButton( + inputState: _inputState, + chatStyle: chatStyle, + onSubmitPrompt: onSubmitPrompt, + onCancelPrompt: onCancelPrompt, + onStartRecording: onStartRecording, + onStopRecording: onStopRecording, + ), + ), + ], + ), + ), + ), + ], + ), + ); + }, + ); + + InputState get _inputState { + if (_waveController.isRecording) return InputState.isRecording; + if (widget.onCancelMessage != null) return InputState.canCancelPrompt; + if (widget.onCancelStt != null) return InputState.canCancelStt; + if (_textController.text.trim().isEmpty) return InputState.canStt; + return InputState.canSubmitPrompt; + } + + void onSubmitPrompt() { + assert(_inputState == InputState.canSubmitPrompt); + + // the mobile vkb can still cause a submission even if there is no text + final text = _textController.text.trim(); + if (text.isEmpty) return; + + widget.onSendMessage(text, List.from(_attachments)); + _attachments.clear(); + _textController.clear(); + _focusNode.requestFocus(); + } + + void onCancelPrompt() { + assert(_inputState == InputState.canCancelPrompt); + widget.onCancelMessage!(); + _focusNode.requestFocus(); + } + + Future onStartRecording() async { + await _waveController.startRecording(); + } + + Future onStopRecording() async { + await _waveController.stopRecording(); + } + + Future onRecordingStopped() async { + final file = _waveController.file; + + if (file == null) { + AdaptiveSnackBar.show(context, 'Unable to record audio'); + return; + } + + // will come back as initialMessage + widget.onTranslateStt(file); + } + + void onAttachments(Iterable attachments) => + setState(() => _attachments.addAll(attachments)); + + void onRemoveAttachment(Attachment attachment) => + setState(() => _attachments.remove(attachment)); +} diff --git a/lib/src/views/chat_input/chat_suggestion_view.dart b/lib/src/views/chat_input/chat_suggestion_view.dart new file mode 100644 index 0000000..6f92ddd --- /dev/null +++ b/lib/src/views/chat_input/chat_suggestion_view.dart @@ -0,0 +1,59 @@ +import 'package:flutter/widgets.dart'; + +import '../../chat_view_model/chat_view_model_client.dart'; +import '../../styles/suggestion_style.dart'; + +/// A widget that displays a list of chat suggestions. +/// +/// This widget takes a list of suggestions and a callback function that is +/// triggered when a suggestion is selected. Each suggestion is displayed +/// as a tappable container with padding and a background color. +@immutable +class ChatSuggestionsView extends StatelessWidget { + /// Creates a [ChatSuggestionsView] widget. + /// + /// The [suggestions] parameter is a list of suggestion strings to display. + /// The [onSelectSuggestion] parameter is a callback function that is called + /// when a suggestion is tapped. + const ChatSuggestionsView({ + required this.suggestions, + required this.onSelectSuggestion, + super.key, + }); + + /// The list of suggestions to display. + final List suggestions; + + /// The callback function to call when a suggestion is selected. + final void Function(String suggestion) onSelectSuggestion; + + @override + Widget build(BuildContext context) => ChatViewModelClient( + builder: (context, viewModel, child) { + final suggestionStyle = SuggestionStyle.resolve( + viewModel.style?.suggestionStyle, + ); + return Wrap( + children: [ + for (final suggestion in suggestions) + GestureDetector( + onTap: () => onSelectSuggestion(suggestion), + child: Padding( + padding: const EdgeInsets.all(8), + child: Container( + padding: const EdgeInsets.all(8), + decoration: suggestionStyle.decoration, + child: Text( + suggestion, + softWrap: true, + maxLines: 3, + style: suggestionStyle.textStyle, + ), + ), + ), + ), + ], + ); + }, + ); +} diff --git a/lib/src/views/chat_input/editing_indicator.dart b/lib/src/views/chat_input/editing_indicator.dart new file mode 100644 index 0000000..6126c21 --- /dev/null +++ b/lib/src/views/chat_input/editing_indicator.dart @@ -0,0 +1,55 @@ +import 'package:flutter/widgets.dart'; +import 'package:gap/gap.dart'; + +import '../../styles/action_button_style.dart'; +import '../../styles/toolkit_text_styles.dart'; +import '../../utility.dart'; +import '../action_button/action_button.dart'; + +/// A widget that displays an editing indicator with a cancel button. +/// +/// This widget is used to show that the user is currently editing a message. +/// It provides a visual indicator with the text "Editing" and a button to +/// cancel the editing action. +/// +/// The [onCancelEdit] callback is triggered when the cancel button is pressed. +/// The [cancelButtonStyle] is used to style the cancel button. +class EditingIndicator extends StatelessWidget { + /// Creates an [EditingIndicator]. + /// + /// The [onCancelEdit] and [cancelButtonStyle] parameters are required. + const EditingIndicator({ + required this.onCancelEdit, + required this.cancelButtonStyle, + super.key, + }); + + /// The callback to be invoked when the cancel button is pressed. + final VoidCallback onCancelEdit; + + /// The style to be applied to the cancel button. + final ActionButtonStyle cancelButtonStyle; + + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.only(right: 16), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + 'Editing', + style: ToolkitTextStyles.label.copyWith( + color: invertColor(cancelButtonStyle.iconColor), + ), + ), + const Gap(6), + ActionButton( + onPressed: onCancelEdit, + style: cancelButtonStyle, + size: 16, + ), + ], + ), + ); +} diff --git a/lib/src/views/chat_input/input_button.dart b/lib/src/views/chat_input/input_button.dart new file mode 100644 index 0000000..8a3bfbf --- /dev/null +++ b/lib/src/views/chat_input/input_button.dart @@ -0,0 +1,75 @@ +// Copyright 2024 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/widgets.dart'; + +import '../../styles/llm_chat_view_style.dart'; +import '../action_button/action_button.dart'; +import '../adaptive_progress_indicator.dart'; +import 'input_state.dart'; + +/// A button widget that adapts its appearance and behavior based on the current +/// input state. +@immutable +class InputButton extends StatelessWidget { + /// Creates an [InputButton]. + /// + /// All parameters are required: + /// - [inputState]: The current state of the input. + /// - [chatStyle]: The style configuration for the chat interface. + /// - [onSubmitPrompt]: Callback function when submitting a prompt. + /// - [onCancelPrompt]: Callback function when cancelling a prompt. + /// - [onStartRecording]: Callback function when starting audio recording. + /// - [onStopRecording]: Callback function when stopping audio recording. + const InputButton({ + required this.inputState, + required this.chatStyle, + required this.onSubmitPrompt, + required this.onCancelPrompt, + required this.onStartRecording, + required this.onStopRecording, + super.key, + }); + + /// The current state of the input. + final InputState inputState; + + /// The style configuration for the chat interface. + final LlmChatViewStyle chatStyle; + + /// Callback function when submitting a prompt. + final void Function() onSubmitPrompt; + + /// Callback function when cancelling a prompt. + final void Function() onCancelPrompt; + + /// Callback function when starting audio recording. + final void Function() onStartRecording; + + /// Callback function when stopping audio recording. + final void Function() onStopRecording; + + @override + Widget build(BuildContext context) => switch (inputState) { + InputState.canSubmitPrompt => ActionButton( + style: chatStyle.submitButtonStyle!, + onPressed: onSubmitPrompt, + ), + InputState.canCancelPrompt => ActionButton( + style: chatStyle.stopButtonStyle!, + onPressed: onCancelPrompt, + ), + InputState.canStt => ActionButton( + style: chatStyle.recordButtonStyle!, + onPressed: onStartRecording, + ), + InputState.isRecording => ActionButton( + style: chatStyle.stopButtonStyle!, + onPressed: onStopRecording, + ), + InputState.canCancelStt => AdaptiveCircularProgressIndicator( + color: chatStyle.progressIndicatorColor!, + ), + }; +} diff --git a/lib/src/views/chat_input/input_state.dart b/lib/src/views/chat_input/input_state.dart new file mode 100644 index 0000000..cdfab21 --- /dev/null +++ b/lib/src/views/chat_input/input_state.dart @@ -0,0 +1,22 @@ +// Copyright 2024 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +/// Represents the different states of the chat input. +enum InputState { + /// The input has text and the submit button is enabled. + canSubmitPrompt, + + /// A prompt is being submitted and the cancel button is enabled. + canCancelPrompt, + + /// The input is empty and the microphone button for speech-to-text is + /// enabled. + canStt, + + /// Speech is being recorded and the stop button is enabled. + isRecording, + + /// Speech is being translated to text and a progress indicator is shown. + canCancelStt, +} diff --git a/lib/src/views/chat_input/removable_attachment.dart b/lib/src/views/chat_input/removable_attachment.dart new file mode 100644 index 0000000..fcea2d7 --- /dev/null +++ b/lib/src/views/chat_input/removable_attachment.dart @@ -0,0 +1,74 @@ +// Copyright 2024 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; + +import 'package:flutter/widgets.dart'; + +import '../../chat_view_model/chat_view_model_client.dart'; +import '../../dialogs/adaptive_dialog.dart'; +import '../../dialogs/image_preview_dialog.dart'; +import '../../providers/interface/attachments.dart'; +import '../../styles/llm_chat_view_style.dart'; +import '../action_button/action_button.dart'; +import '../attachment_view/attachment_view.dart'; + +/// A widget that displays an attachment with a remove button. +@immutable +class RemovableAttachment extends StatelessWidget { + /// Creates a [RemovableAttachment]. + /// + /// The [attachment] parameter is required and represents the attachment to + /// display. The [onRemove] parameter is a callback function that is called + /// when the remove button is pressed. + const RemovableAttachment({ + required this.attachment, + required this.onRemove, + super.key, + }); + + /// The attachment to display. + final Attachment attachment; + + /// Callback function that is called when the remove button is pressed. + /// + /// The [Attachment] to be removed is passed as an argument to this function. + final Function(Attachment) onRemove; + + @override + Widget build(BuildContext context) => Stack( + children: [ + GestureDetector( + onTap: attachment is ImageFileAttachment + ? () => unawaited(_showPreviewDialog(context)) + : null, + child: Container( + padding: const EdgeInsets.only(right: 12), + height: 80, + child: AttachmentView(attachment), + ), + ), + Padding( + padding: const EdgeInsets.all(2), + child: ChatViewModelClient( + builder: (context, viewModel, child) { + final chatStyle = LlmChatViewStyle.resolve(viewModel.style); + return ActionButton( + style: chatStyle.closeButtonStyle!, + size: 20, + onPressed: () => onRemove(attachment), + ); + }, + ), + ), + ], + ); + + Future _showPreviewDialog(BuildContext context) async => + AdaptiveAlertDialog.show( + context: context, + barrierDismissible: true, + content: ImagePreviewDialog(attachment as ImageFileAttachment), + ); +} diff --git a/lib/src/views/chat_message_view/adaptive_copy_text.dart b/lib/src/views/chat_message_view/adaptive_copy_text.dart new file mode 100644 index 0000000..7c4f5f8 --- /dev/null +++ b/lib/src/views/chat_message_view/adaptive_copy_text.dart @@ -0,0 +1,76 @@ +import 'dart:async'; + +import 'package:flutter/material.dart' + show DefaultMaterialLocalizations, SelectionArea; +import 'package:flutter/widgets.dart'; +import 'package:flutter_context_menu/flutter_context_menu.dart'; + +import '../../styles/llm_chat_view_style.dart'; +import '../../utility.dart'; + +/// A widget that displays text with adaptive copy functionality. +/// +/// This widget provides a context menu for copying text to the clipboard on +/// mobile devices, and a selection area for mouse-driven selection on desktop +/// and web platforms. +@immutable +class AdaptiveCopyText extends StatelessWidget { + /// Creates an [AdaptiveCopyText] widget. + /// + /// The [clipboardText] parameter is required and contains the text to be + /// copied to the clipboard. The [child] parameter is required and contains + /// the widget to be displayed. The [chatStyle] parameter is required and + /// contains the style information for the chat. The [onEdit] parameter is + /// optional and contains the callback to be invoked when the text is edited. + const AdaptiveCopyText({ + required this.clipboardText, + required this.child, + required this.chatStyle, + this.onEdit, + super.key, + }); + + /// The text to be copied to the clipboard. + final String clipboardText; + + /// The widget to be displayed. + final Widget child; + + /// The callback to be invoked when the text is edited. + final VoidCallback? onEdit; + + /// The style information for the chat. + final LlmChatViewStyle chatStyle; + + @override + Widget build(BuildContext context) { + final contextMenu = ContextMenu( + entries: [ + if (onEdit != null) + MenuItem( + label: 'Edit', + icon: chatStyle.editButtonStyle!.icon, + onSelected: onEdit, + ), + MenuItem( + label: 'Copy', + icon: chatStyle.copyButtonStyle!.icon, + onSelected: () => unawaited(copyToClipboard(context, clipboardText)), + ), + ], + ); + + // On mobile, show the context menu for long-press; + // on desktop and web, show the selection area for mouse-driven selection. + return isMobile + ? ContextMenuRegion(contextMenu: contextMenu, child: child) + : Localizations( + locale: Localizations.localeOf(context), + delegates: const [ + DefaultWidgetsLocalizations.delegate, + DefaultMaterialLocalizations.delegate, + ], + child: SelectionArea(child: child), + ); + } +} diff --git a/lib/src/views/chat_message_view/hovering_buttons.dart b/lib/src/views/chat_message_view/hovering_buttons.dart new file mode 100644 index 0000000..97acbf0 --- /dev/null +++ b/lib/src/views/chat_message_view/hovering_buttons.dart @@ -0,0 +1,103 @@ +import 'dart:async'; + +import 'package:flutter/widgets.dart'; +import 'package:gap/gap.dart'; + +import '../../styles/llm_chat_view_style.dart'; +import '../../utility.dart'; + +/// A widget that displays hovering buttons for editing and copying. +/// +/// This widget is a [StatefulWidget] that shows buttons for editing and copying +/// when the user hovers over the child widget. The buttons are displayed at the +/// bottom right of the child widget. +class HoveringButtons extends StatelessWidget { + /// Creates a [HoveringButtons] widget. + /// + /// The [onEdit] callback is invoked when the edit button is pressed. The + /// [child] widget is the content over which the buttons will hover. + HoveringButtons({ + required this.chatStyle, + required this.isUserMessage, + required this.child, + this.clipboardText, + this.onEdit, + super.key, + }); + + /// The style information for the chat. + final LlmChatViewStyle chatStyle; + + /// Whether the message is a user message. + final bool isUserMessage; + + /// The text to be copied to the clipboard. + final String? clipboardText; + + /// The child widget over which the buttons will hover. + final Widget child; + + /// The callback to be invoked when the edit button is pressed. + final VoidCallback? onEdit; + + static const _iconSize = 16; + final _hovering = ValueNotifier(false); + + @override + Widget build(BuildContext context) { + final paddedChild = Padding( + padding: const EdgeInsets.only(bottom: _iconSize + 2), + child: child, + ); + + return clipboardText == null + ? paddedChild + : MouseRegion( + onEnter: (_) => _hovering.value = true, + onExit: (_) => _hovering.value = false, + child: Stack( + children: [ + paddedChild, + ListenableBuilder( + listenable: _hovering, + builder: (context, child) => _hovering.value + ? Positioned( + bottom: 0, + right: isUserMessage ? 0 : null, + left: isUserMessage ? null : 32, + child: Row( + children: [ + if (onEdit != null) + GestureDetector( + onTap: onEdit, + child: Icon( + chatStyle.editButtonStyle!.icon, + size: _iconSize.toDouble(), + color: invertColor( + chatStyle.editButtonStyle!.iconColor, + ), + ), + ), + const Gap(6), + GestureDetector( + onTap: () => unawaited( + copyToClipboard(context, clipboardText!), + ), + child: Icon( + chatStyle.copyButtonStyle!.icon, + size: 12, + color: invertColor( + chatStyle.copyButtonStyle!.iconColor, + ), + ), + ), + ], + ), + ) + : const SizedBox(), + ), + ], + ), + ); + } +} diff --git a/lib/src/views/chat_message_view/llm_message_view.dart b/lib/src/views/chat_message_view/llm_message_view.dart new file mode 100644 index 0000000..2dbd2be --- /dev/null +++ b/lib/src/views/chat_message_view/llm_message_view.dart @@ -0,0 +1,108 @@ +// Copyright 2024 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/widgets.dart'; +import 'package:flutter_markdown/flutter_markdown.dart'; + +import '../../chat_view_model/chat_view_model_client.dart'; +import '../../providers/interface/chat_message.dart'; +import '../../styles/llm_chat_view_style.dart'; +import '../../styles/llm_message_style.dart'; +import '../jumping_dots_progress_indicator/jumping_dots_progress_indicator.dart'; +import 'adaptive_copy_text.dart'; +import 'hovering_buttons.dart'; + +/// A widget that displays an LLM (Language Model) message in a chat interface. +@immutable +class LlmMessageView extends StatelessWidget { + /// Creates an [LlmMessageView]. + /// + /// The [message] parameter is required and represents the LLM chat message to + /// be displayed. + const LlmMessageView( + this.message, { + this.isWelcomeMessage = false, + super.key, + }); + + /// The LLM chat message to be displayed. + final ChatMessage message; + + /// Whether the message is the welcome message. + final bool isWelcomeMessage; + + @override + Widget build(BuildContext context) => Row( + children: [ + Flexible( + flex: 6, + child: Column( + children: [ + ChatViewModelClient( + builder: (context, viewModel, child) { + final text = message.text; + final chatStyle = LlmChatViewStyle.resolve(viewModel.style); + final llmStyle = LlmMessageStyle.resolve( + chatStyle.llmMessageStyle, + ); + + return Stack( + children: [ + Padding( + padding: const EdgeInsets.only(top: 10), + child: Container( + height: 20, + width: 20, + decoration: llmStyle.iconDecoration, + child: Icon( + llmStyle.icon, + color: llmStyle.iconColor, + size: 12, + ), + ), + ), + HoveringButtons( + isUserMessage: false, + chatStyle: chatStyle, + clipboardText: text, + child: Container( + decoration: llmStyle.decoration, + margin: const EdgeInsets.only(left: 28), + padding: const EdgeInsets.all(8), + child: text == null + ? SizedBox( + width: 24, + child: JumpingDotsProgressIndicator( + fontSize: 24, + color: chatStyle.progressIndicatorColor!, + ), + ) + : AdaptiveCopyText( + clipboardText: text, + chatStyle: chatStyle, + child: isWelcomeMessage || + viewModel.responseBuilder == null + ? MarkdownBody( + data: text, + selectable: false, + styleSheet: llmStyle.markdownStyle, + ) + : viewModel.responseBuilder!( + context, + text, + ), + ), + ), + ), + ], + ); + }, + ), + ], + ), + ), + const Flexible(flex: 2, child: SizedBox()), + ], + ); +} diff --git a/lib/src/views/chat_message_view/user_message_view.dart b/lib/src/views/chat_message_view/user_message_view.dart new file mode 100644 index 0000000..db91d4f --- /dev/null +++ b/lib/src/views/chat_message_view/user_message_view.dart @@ -0,0 +1,92 @@ +// Copyright 2024 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/widgets.dart'; + +import '../../chat_view_model/chat_view_model_client.dart'; +import '../../providers/interface/chat_message.dart'; +import '../../styles/styles.dart'; +import '../attachment_view/attachment_view.dart'; +import 'adaptive_copy_text.dart'; +import 'hovering_buttons.dart'; + +/// A widget that displays a user's message in a chat interface. +/// +/// This widget is responsible for rendering the user's message, including any +/// attachments, in a right-aligned layout. It uses a [Row] and [Column] to +/// structure the content, with the message text displayed in a styled +/// container. +@immutable +class UserMessageView extends StatelessWidget { + /// Creates a [UserMessageView]. + /// + /// The [message] parameter is required and contains the [ChatMessage] to be + /// displayed. + const UserMessageView(this.message, {super.key, this.onEdit}); + + /// The chat message to be displayed. + final ChatMessage message; + + /// The callback to be invoked when the message is edited. + final VoidCallback? onEdit; + + @override + Widget build(BuildContext context) => Column( + children: [ + ...[ + for (final attachment in message.attachments) + Padding( + padding: const EdgeInsets.only(bottom: 6), + child: Align( + alignment: Alignment.topRight, + child: SizedBox( + height: 80, + width: 200, + child: AttachmentView(attachment), + ), + ), + ), + ], + ChatViewModelClient( + builder: (context, viewModel, child) { + final text = message.text!; + final chatStyle = LlmChatViewStyle.resolve(viewModel.style); + final userStyle = UserMessageStyle.resolve( + chatStyle.userMessageStyle, + ); + + return Align( + alignment: Alignment.topRight, + child: Padding( + padding: const EdgeInsets.only(right: 16), + child: HoveringButtons( + isUserMessage: true, + chatStyle: chatStyle, + clipboardText: text, + onEdit: onEdit, + child: DecoratedBox( + decoration: userStyle.decoration!, + child: Padding( + padding: const EdgeInsets.only( + left: 16, + right: 16, + top: 12, + bottom: 12, + ), + child: AdaptiveCopyText( + chatStyle: chatStyle, + clipboardText: text, + onEdit: onEdit, + child: Text(text, style: userStyle.textStyle), + ), + ), + ), + ), + ), + ); + }, + ), + ], + ); +} diff --git a/lib/src/views/chat_text_field.dart b/lib/src/views/chat_text_field.dart new file mode 100644 index 0000000..fd8ebf7 --- /dev/null +++ b/lib/src/views/chat_text_field.dart @@ -0,0 +1,105 @@ +// Copyright 2024 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/cupertino.dart' show CupertinoTextField; +import 'package:flutter/material.dart' + show InputBorder, InputDecoration, TextField, TextInputAction; +import 'package:flutter/widgets.dart'; + +import '../styles/toolkit_colors.dart'; +import '../utility.dart'; + +/// A text field that adapts to the current app style (Material or Cupertino). +/// +/// This widget will render either a [CupertinoTextField] or a [TextField] +/// depending on whether the app is using Cupertino or Material design. +@immutable +class ChatTextField extends StatelessWidget { + /// Creates an adaptive text field. + /// + /// Many of the parameters are required to ensure consistent behavior + /// across both Cupertino and Material designs. + const ChatTextField({ + required this.minLines, + required this.maxLines, + required this.autofocus, + required this.style, + required this.textInputAction, + required this.controller, + required this.focusNode, + required this.onSubmitted, + required this.hintText, + required this.hintStyle, + required this.hintPadding, + super.key, + }); + + /// The minimum number of lines to show. + final int minLines; + + /// The maximum number of lines to show. + final int maxLines; + + /// Whether the text field should be focused initially. + final bool autofocus; + + /// The style to use for the text being edited. + final TextStyle style; + + /// The type of action button to use for the keyboard. + final TextInputAction textInputAction; + + /// Controls the text being edited. + final TextEditingController controller; + + /// Defines the keyboard focus for this widget. + final FocusNode focusNode; + + /// The text to show when the text field is empty. + final String hintText; + + /// The style to use for the hint text. + final TextStyle hintStyle; + + /// The padding to use for the hint text. + final EdgeInsetsGeometry? hintPadding; + + /// Called when the user submits editable content. + final void Function(String text) onSubmitted; + + @override + Widget build(BuildContext context) => isCupertinoApp(context) + ? CupertinoTextField( + minLines: minLines, + maxLines: maxLines, + controller: controller, + autofocus: autofocus, + focusNode: focusNode, + onSubmitted: onSubmitted, + style: style, + placeholder: hintText, + placeholderStyle: hintStyle, + padding: hintPadding ?? EdgeInsets.zero, + decoration: BoxDecoration( + border: Border.all(width: 0, color: ToolkitColors.transparent), + ), + textInputAction: textInputAction, + ) + : TextField( + minLines: minLines, + maxLines: maxLines, + controller: controller, + autofocus: autofocus, + focusNode: focusNode, + textInputAction: textInputAction, + onSubmitted: onSubmitted, + style: style, + decoration: InputDecoration( + border: InputBorder.none, + hintText: hintText, + hintStyle: hintStyle, + contentPadding: hintPadding, + ), + ); +} diff --git a/lib/src/views/jumping_dots_progress_indicator/jumping_dot.dart b/lib/src/views/jumping_dots_progress_indicator/jumping_dot.dart new file mode 100644 index 0000000..5174984 --- /dev/null +++ b/lib/src/views/jumping_dots_progress_indicator/jumping_dot.dart @@ -0,0 +1,42 @@ +// Copyright 2024 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/widgets.dart'; + +/// A widget that represents a single jumping dot in the progress indicator. +@immutable +class JumpingDot extends AnimatedWidget { + /// Creates a [JumpingDot] widget. + /// + /// The [animation] parameter is required and controls the vertical movement + /// of the dot. The [color] parameter sets the color of the dot. The + /// [fontSize] parameter determines the size of the dot. + const JumpingDot({ + required Animation animation, + required this.color, + required this.fontSize, + super.key, + }) : super(listenable: animation); + + /// The color of the dot. + final Color color; + + /// The font size of the dot. + final double fontSize; + + Animation get _animation => listenable as Animation; + + @override + Widget build(BuildContext context) => SizedBox( + height: _animation.value + fontSize, + child: Text( + '.', + style: TextStyle( + color: color, + fontSize: fontSize, + height: 1, // Center the text vertically within its line height + ), + ), + ); +} diff --git a/lib/src/views/jumping_dots_progress_indicator/jumping_dots_progress_indicator.dart b/lib/src/views/jumping_dots_progress_indicator/jumping_dots_progress_indicator.dart new file mode 100644 index 0000000..57123d9 --- /dev/null +++ b/lib/src/views/jumping_dots_progress_indicator/jumping_dots_progress_indicator.dart @@ -0,0 +1,126 @@ +// Copyright 2024 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// this file forked from https://github.com/wal33d006/progress_indicators due to +// lack of activity + +import 'package:flutter/widgets.dart'; + +import 'jumping_dot.dart'; + +/// Creates a list with [numberOfDots] text dots, with 3 dots as default +/// default [fontSize] of 10.0, default [color] as black, [dotSpacing] (gap +/// between each dot) as 0.0 and default time for one cycle of animation +/// [milliseconds] as 250. +/// One cycle of animation is one complete round of a dot animating up and back +/// to its original position. +@immutable +class JumpingDotsProgressIndicator extends StatefulWidget { + /// Creates a jumping dot progress indicator. + const JumpingDotsProgressIndicator({ + required this.color, + super.key, + this.numberOfDots = 3, + this.fontSize = 10.0, + this.dotSpacing = 0.0, + this.milliseconds = 250, + }); + + /// Number of dots that are added in a horizontal list, default = 3. + final int numberOfDots; + + /// Font size of each dot, default = 10.0. + final double fontSize; + + /// Spacing between each dot, default 0.0. + final double dotSpacing; + + /// Color of the dots, default black. + final Color color; + + /// Time of one complete cycle of animation, default 250 milliseconds. + final int milliseconds; + + @override + _JumpingDotsProgressIndicatorState createState() => + _JumpingDotsProgressIndicatorState(); +} + +class _JumpingDotsProgressIndicatorState + extends State with TickerProviderStateMixin { + final _controllers = []; + final _animations = >[]; + final _widgets = []; + static const double _beginTweenValue = 0; + static const double _endTweenValue = 8; + + @override + void initState() { + super.initState(); + + // for each dot... + for (var dot = 0; dot < widget.numberOfDots; dot++) { + // add an animation controller for the dot + _controllers.add(AnimationController( + duration: Duration(milliseconds: widget.milliseconds), + vsync: this, + )); + + // build an animation for the dot using the controller + _animations.add( + Tween(begin: _beginTweenValue, end: _endTweenValue) + .animate(_controllers[dot]) + ..addStatusListener((status) => _dotListener(status, dot)), + ); + + // add a dot widget with that animation + _widgets.add( + Padding( + padding: EdgeInsets.only(right: widget.dotSpacing), + child: JumpingDot( + animation: _animations[dot], + fontSize: widget.fontSize, + color: widget.color, + ), + ), + ); + } + + // start the animation + _controllers[0].forward(); + } + + void _dotListener(AnimationStatus status, int dot) { + if (status == AnimationStatus.completed) { + _controllers[dot].reverse(); + } + + if (dot == widget.numberOfDots - 1 && status == AnimationStatus.dismissed) { + _controllers[0].forward(); + } + + if (_animations[dot].value > _endTweenValue / 2 && + dot < widget.numberOfDots - 1) { + _controllers[dot + 1].forward(); + } + } + + @override + Widget build(BuildContext context) => SizedBox( + height: widget.fontSize + (widget.fontSize * 0.5), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: _widgets, + ), + ); + + @override + void dispose() { + for (var i = 0; i < widget.numberOfDots; i++) { + _controllers[i].dispose(); + } + + super.dispose(); + } +} diff --git a/lib/src/views/llm_chat_view/llm_chat_view.dart b/lib/src/views/llm_chat_view/llm_chat_view.dart new file mode 100644 index 0000000..45c1fab --- /dev/null +++ b/lib/src/views/llm_chat_view/llm_chat_view.dart @@ -0,0 +1,332 @@ +// Copyright 2024 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; + +import 'package:cross_file/cross_file.dart'; +import 'package:flutter/widgets.dart'; + +import '../../chat_view_model/chat_view_model.dart'; +import '../../chat_view_model/chat_view_model_provider.dart'; +import '../../dialogs/adaptive_dialog.dart'; +import '../../dialogs/adaptive_dialog_action.dart'; +import '../../dialogs/adaptive_snack_bar/adaptive_snack_bar.dart'; +import '../../llm_exception.dart'; +import '../../platform_helper/platform_helper.dart' as ph; +import '../../providers/interface/attachments.dart'; +import '../../providers/interface/chat_message.dart'; +import '../../providers/interface/llm_provider.dart'; +import '../../styles/llm_chat_view_style.dart'; +import '../chat_history_view.dart'; +import '../chat_input/chat_input.dart'; +import '../chat_input/chat_suggestion_view.dart'; +import '../response_builder.dart'; +import 'llm_response.dart'; + +/// A widget that displays a chat interface for interacting with an LLM +/// (Language Model). +/// +/// This widget provides a complete chat interface, including a message history +/// view and an input area for sending new messages. It is configured with an +/// [LlmProvider] to manage the chat interactions. +/// +/// Example usage: +/// ```dart +/// LlmChatView( +/// provider: MyLlmProvider(), +/// style: LlmChatViewStyle( +/// backgroundColor: Colors.white, +/// // ... other style properties +/// ), +/// ) +/// ``` +@immutable +class LlmChatView extends StatefulWidget { + /// Creates an [LlmChatView] widget. + /// + /// This widget provides a chat interface for interacting with an LLM + /// (Language Model). It requires an [LlmProvider] to manage the chat + /// interactions and can be customized with various style and configuration + /// options. + /// + /// - [provider]: The [LlmProvider] that manages the chat interactions. + /// - [style]: Optional. The [LlmChatViewStyle] to customize the appearance of + /// the chat interface. + /// - [responseBuilder]: Optional. A custom [ResponseBuilder] to handle the + /// display of LLM responses. + /// - [messageSender]: Optional. A custom [LlmStreamGenerator] to handle the + /// sending of messages. If provided, this is used instead of the + /// `sendMessageStream` method of the provider. It's the responsibility of + /// the caller to ensure that the [messageSender] properly streams the + /// response. This is useful for augmenting the user's prompt with + /// additional information, in the case of prompt engineering or RAG. It's + /// also useful for simple logging. + /// - [suggestions]: Optional. A list of predefined suggestions to display + /// when the chat history is empty. Defaults to an empty list. + /// - [welcomeMessage]: Optional. A welcome message to display when the chat + /// is first opened. + LlmChatView({ + required LlmProvider provider, + LlmChatViewStyle? style, + ResponseBuilder? responseBuilder, + LlmStreamGenerator? messageSender, + this.suggestions = const [], + String? welcomeMessage, + super.key, + }) : viewModel = ChatViewModel( + provider: provider, + responseBuilder: responseBuilder, + messageSender: messageSender, + style: style, + welcomeMessage: welcomeMessage, + ); + + /// The list of suggestions to display in the chat interface. + /// + /// This list contains predefined suggestions that can be shown to the user + /// when the chat history is empty. The user can select any of these + /// suggestions to quickly start a conversation with the LLM. + final List suggestions; + + /// The view model containing the chat state and configuration. + /// + /// This [ChatViewModel] instance holds the LLM provider, transcript, + /// response builder, welcome message, and LLM icon for the chat interface. + /// It encapsulates the core data and functionality needed for the chat view. + late final ChatViewModel viewModel; + + @override + State createState() => _LlmChatViewState(); +} + +class _LlmChatViewState extends State + with AutomaticKeepAliveClientMixin { + @override + bool get wantKeepAlive => true; + + LlmResponse? _pendingPromptResponse; + ChatMessage? _initialMessage; + ChatMessage? _associatedResponse; + LlmResponse? _pendingSttResponse; + + @override + void initState() { + super.initState(); + widget.viewModel.provider.addListener(_onHistoryChanged); + } + + @override + void dispose() { + super.dispose(); + widget.viewModel.provider.removeListener(_onHistoryChanged); + } + + @override + Widget build(BuildContext context) { + super.build(context); // for AutomaticKeepAliveClientMixin + + final chatStyle = LlmChatViewStyle.resolve(widget.viewModel.style); + return ListenableBuilder( + listenable: widget.viewModel.provider, + builder: (context, child) => ChatViewModelProvider( + viewModel: widget.viewModel, + child: Container( + color: chatStyle.backgroundColor, + child: Column( + children: [ + Expanded( + child: Stack( + children: [ + ChatHistoryView( + // can only edit if we're not waiting on the LLM or if + // we're not already editing an LLM response + onEditMessage: _pendingPromptResponse == null && + _associatedResponse == null + ? _onEditMessage + : null, + ), + if (widget.suggestions.isNotEmpty && + widget.viewModel.provider.history.isEmpty) + Align( + alignment: Alignment.topCenter, + child: ChatSuggestionsView( + suggestions: widget.suggestions, + onSelectSuggestion: _onSelectSuggestion, + ), + ), + ], + ), + ), + ChatInput( + initialMessage: _initialMessage, + onCancelEdit: + _associatedResponse != null ? _onCancelEdit : null, + onSendMessage: _onSendMessage, + onCancelMessage: + _pendingPromptResponse == null ? null : _onCancelMessage, + onTranslateStt: _onTranslateStt, + onCancelStt: _pendingSttResponse == null ? null : _onCancelStt, + ), + ], + ), + ), + ), + ); + } + + Future _onSendMessage( + String prompt, + Iterable attachments, + ) async { + _initialMessage = null; + _associatedResponse = null; + + // check the viewmodel for a user-provided message sender to use instead + final sendMessageStream = widget.viewModel.messageSender ?? + widget.viewModel.provider.sendMessageStream; + + _pendingPromptResponse = LlmResponse( + stream: sendMessageStream(prompt, attachments: attachments), + // update during the streaming response input so that the end-user can see + // the response as it streams in + onUpdate: (_) => setState(() {}), + onDone: _onPromptDone, + ); + + setState(() {}); + } + + void _onPromptDone(LlmException? error) { + setState(() => _pendingPromptResponse = null); + unawaited(_showLlmException(error)); + } + + void _onCancelMessage() => _pendingPromptResponse?.cancel(); + + void _onEditMessage(ChatMessage message) { + assert(_pendingPromptResponse == null); + + // remove the last llm message + final history = widget.viewModel.provider.history.toList(); + assert(history.last.origin.isLlm); + final llmMessage = history.removeLast(); + + // remove the last user message + assert(history.last.origin.isUser); + final userMessage = history.removeLast(); + + // set the history to the new history + widget.viewModel.provider.history = history; + + // set the text to the last userMessage to provide initial prompt and + // attachments for the user to edit + setState(() { + _initialMessage = userMessage; + _associatedResponse = llmMessage; + }); + } + + Future _onTranslateStt(XFile file) async { + _initialMessage = null; + _associatedResponse = null; + + // use the LLM to translate the attached audio to text + const prompt = + 'translate the attached audio to text; provide the result of that ' + 'translation as just the text of the translation itself. be careful to ' + 'separate the background audio from the foreground audio and only ' + 'provide the result of translating the foreground audio.'; + final attachments = [await FileAttachment.fromFile(file)]; + + var response = ''; + _pendingSttResponse = LlmResponse( + stream: widget.viewModel.provider.generateStream( + prompt, + attachments: attachments, + ), + onUpdate: (text) => response += text, + onDone: (error) async => _onSttDone(error, response, file), + ); + + setState(() {}); + } + + Future _onSttDone( + LlmException? error, + String response, + XFile file, + ) async { + assert(_pendingSttResponse != null); + setState(() { + _initialMessage = ChatMessage.user(response, []); + _pendingSttResponse = null; + }); + + // delete the file now that the LLM has translated it + unawaited(ph.deleteFile(file)); + + // show any error that occurred + unawaited(_showLlmException(error)); + } + + void _onCancelStt() => _pendingSttResponse?.cancel(); + + Future _showLlmException(LlmException? error) async { + if (error == null) return; + + // stop from the progress from indicating in case there was a failure + // before any text response happened; the progress indicator uses a null + // text message to keep progressing. plus we don't want to just show an + // empty LLM message. + final llmMessage = widget.viewModel.provider.history.last; + if (llmMessage.text == null) { + llmMessage.append(error is LlmCancelException ? 'CANCEL' : 'ERROR'); + } + + switch (error) { + case LlmCancelException(): + AdaptiveSnackBar.show(context, 'LLM operation canceled by user'); + case LlmFailureException(): + case LlmException(): + await AdaptiveAlertDialog.show( + context: context, + content: Text(error.toString()), + actions: [ + AdaptiveDialogAction( + onPressed: () => Navigator.pop(context), + child: const Text('OK'), + ), + ], + ); + } + } + + void _onSelectSuggestion(String suggestion) => + setState(() => _initialMessage = ChatMessage.user(suggestion, [])); + + void _onHistoryChanged() { + // if the history is cleared, clear the initial message + if (widget.viewModel.provider.history.isEmpty) { + setState(() { + _initialMessage = null; + _associatedResponse = null; + }); + } + } + + void _onCancelEdit() { + assert(_initialMessage != null); + assert(_associatedResponse != null); + + // add the original message and response back to the history + final history = widget.viewModel.provider.history.toList(); + history.addAll([_initialMessage!, _associatedResponse!]); + widget.viewModel.provider.history = history; + + setState(() { + _initialMessage = null; + _associatedResponse = null; + }); + } +} diff --git a/lib/src/views/llm_chat_view/llm_response.dart b/lib/src/views/llm_chat_view/llm_response.dart new file mode 100644 index 0000000..c829a15 --- /dev/null +++ b/lib/src/views/llm_chat_view/llm_response.dart @@ -0,0 +1,57 @@ +// Copyright 2024 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; + +import '../../llm_exception.dart'; + +/// Represents a response from an LLM (Language Learning Model). +/// +/// This class manages the streaming of LLM responses, error handling, and +/// cleanup. +class LlmResponse { + /// Creates an LlmResponse. + /// + /// [stream] is the stream of text chunks from the LLM. [onDone] is an + /// optional callback for when the response is complete or encounters an + /// error. + LlmResponse({ + required Stream stream, + required this.onUpdate, + required this.onDone, + }) { + _subscription = stream.listen( + onUpdate, + onDone: () => onDone(null), + cancelOnError: true, + onError: (err) => _close(_exception(err)), + ); + } + + /// Callback function to be called when a new chunk is received from the + /// response stream. + final void Function(String text) onUpdate; + + /// Callback function to be called when the response is complete or encounters + /// an error. + final void Function(LlmException? error) onDone; + + /// Cancels the response stream. + void cancel() => _close(const LlmCancelException()); + + StreamSubscription? _subscription; + + LlmException _exception(dynamic err) => switch (err) { + (LlmCancelException _) => const LlmCancelException(), + (final LlmFailureException ex) => ex, + _ => LlmFailureException(err.toString()), + }; + + void _close(LlmException error) { + assert(_subscription != null); + unawaited(_subscription!.cancel()); + _subscription = null; + onDone.call(error); + } +} diff --git a/lib/src/views/response_builder.dart b/lib/src/views/response_builder.dart new file mode 100644 index 0000000..4add43c --- /dev/null +++ b/lib/src/views/response_builder.dart @@ -0,0 +1,20 @@ +// Copyright 2024 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/widgets.dart'; + +/// A function type that defines how to build a widget for displaying a response +/// in the chat interface. +/// +/// [context] is the build context, which can be used to access theme data and +/// other contextual information. +/// +/// [response] is the text of the response from the LLM. +/// +/// The function should return a [Widget] that represents the formatted response +/// in the chat interface. +typedef ResponseBuilder = Widget Function( + BuildContext context, + String response, +); diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..8dffef0 --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,37 @@ +name: flutter_ai_toolkit +description: "A set of AI chat-related widgets for your Flutter app targeting mobile, desktop and web." +version: 0.6.5 +homepage: https://github.com/flutter/ai + +environment: + sdk: '>=3.4.0 <4.0.0' + flutter: ">=1.17.0" + +dependencies: + cross_file: ^0.3.4+2 + file_selector: ^1.0.3 + firebase_vertexai: ^1.0.1 + flutter: + sdk: flutter + flutter_context_menu: ^0.2.0 + flutter_markdown: ^0.7.4+3 + flutter_picture_taker: ^0.2.0 + gap: ^3.0.1 + google_fonts: ^6.2.1 + google_generative_ai: ^0.4.3 + image_picker: ^1.1.2 + mime: ^2.0.0 + universal_platform: ^1.1.0 + uuid: ^4.4.2 + waveform_recorder: ^1.3.0 + +dev_dependencies: + flutter_lints: ^5.0.0 + flutter_test: + sdk: flutter + +flutter: + fonts: + - family: FatIcons + fonts: + - asset: lib/fonts/FatIcons.ttf