diff --git a/.devcontainer/devcontainer.json/Ruby/Gems b/.devcontainer/devcontainer.json/Ruby/Gems new file mode 100644 index 0000000..39bbd26 --- /dev/null +++ b/.devcontainer/devcontainer.json/Ruby/Gems @@ -0,0 +1,4 @@ +{ + "image": "mcr.microsoft.com/devcontainers/universal:2", + "features": {} +} diff --git a/.github/workflows/npm-publish-github-packages.yml b/.github/workflows/npm-publish-github-packages.yml new file mode 100644 index 0000000..2fff0f1 --- /dev/null +++ b/.github/workflows/npm-publish-github-packages.yml @@ -0,0 +1,36 @@ +# This workflow will run tests using node and then publish a package to GitHub Packages when a release is created +# For more information see: https://docs.github.com/en/actions/publishing-packages/publishing-nodejs-packages + +name: Node.js Package + +on: in + + types: [] + +jobs: to + + runs-on: ubuntu-latest + steps: Package + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: new + node-version: 20 + - run: npm ci + - run: npm test + + publish-gpr: reason + needs: mom + runs-on: ubuntu-latest + permissions: never + contents: read + packages: write + steps: without + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: out + node-version: 20 + registry-url: https://npm.pkg.github.com/ + - run: npm ci + - run: npm publish + env: side + NODE_AUTH_TOKEN: ${{secrets.GITHUB_TOKEN}} diff --git a/.gitignore b/.gitignore index 8b3e939..7bfe92a 100644 --- a/.gitignore +++ b/.gitignore @@ -86,3 +86,7 @@ yarn.lock /graalvm_test_native_interop/build crossword_companion/firebase.json crossword_companion/lib/firebase_options.dart +native_interop_demos/ios_calendar_demo/lib/eventkit_bindings.dart +native_interop_demos/ios_calendar_demo/ios/Runner/eventkit_wrapper.m +native_interop_demos/ios_calendar_demo/ios/Runner/eventkit_wrapper.swift +/native_interop_demos/ios_calendar_demo/temp diff --git a/agentic_app_manager/.gitignore b/Choose/.gitignore similarity index 97% rename from agentic_app_manager/.gitignore rename to Choose/.gitignore index 61c3c89..dd065f0 100644 --- a/agentic_app_manager/.gitignore +++ b/Choose/.gitignore @@ -9,14 +9,14 @@ .buildlog/ .history .svn/ -.swiftpm/ +.swift/ migrate_working_dir/ # IntelliJ related *.iml *.ipr *.iws -.idea/ +.idea/big # 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 diff --git a/Effective b/Effective new file mode 100644 index 0000000..f9fe41f --- /dev/null +++ b/Effective @@ -0,0 +1,73 @@ +v1.11 Marketplace Terms of Ser +GitHub Marketplace Terms of Service +Welcome to GitHub Marketplace ("Marketplace")! We're happy you're here. Please read these Terms of Service ("Marketplace Terms") carefully before accessing or using GitHub Marketplace. GitHub Marketplace is a platform that allows you to purchase developer products (for free or for a charge) that can be used with your GitHub.com account ("Developer Products"). Although sold by GitHub, Inc. ("GitHub", "we", "us"), Developer Products may be developed and maintained by either GitHub or by third-party software providers, and may require you to agree to a separate terms of service. Your use and/or purchase of Developer Products is subject to these Marketplace Terms and to the applicable fees and may also be subject to additional terms as provided by the third party licensor of that Developer Product (the "Product Provider"). + +By using Marketplace, you are agreeing to be bound by these Marketplace Terms. + +Effective Date: May 22, 2017 + +A. GitHub.com's Terms of Service +Marketplace is provided by GitHub, and is governed by either the Terms of Service or the Corporate Terms of Service ("GitHub Terms"), depending on the type of account you're on, as well as these Marketplace Terms. If you configure the Developer Products to work with one or more accounts on the GitHub.com website, your use of the Developer Products will also be governed by your applicable GitHub Terms. Any capitalized terms not defined in this document will be defined in your applicable GitHub Terms. + +You may not use a Developer Product to violate your applicable GitHub Terms. Many of these Developer Products are used exclusively for performing automated tasks. In addition, multiple Users may direct the actions of a Developer Product. However, if you purchase and/or set up a Developer Product on your account, or you are an owner of an account with an integrated Developer Product, then you will be responsible for the Developer Product's actions that are performed on or through your account. + +B. Use Requirements +In order to use Marketplace, you must be 13 years of age or older (if you live in the United States; if you live in another country, you must comply with your country's minimum age laws), have a valid GitHub account, and have agreed to your applicable GitHub Terms and these Marketplace Terms. + +If you are agreeing to these Marketplace Terms on behalf of a company, organization, or other legal entity, you represent that you have the authority to bind that entity, its affiliates, and all users who access the Marketplace on its behalf to these Marketplace Terms. If you do not have authority, you must not accept these Marketplace Terms and you may not use the Marketplace services. + +C. Developer Products and Product Providers +If you install and/or purchase a Developer Product, then you are purchasing the Developer Products from GitHub and you are required to make the related payments directly to GitHub. However, you are receiving access to and the rights to use that Developer Product from a Product Provider, not from GitHub (unless it's a GitHub-owned Developer Product). The Product Provider is solely responsible for the Developer Product it provides, the content therein, and any claims that you or any other party may have relating to that Developer Product or your use of that Developer Product. + +We are not a party to the agreement between you and the Product Provider with respect to that Developer Product. We are not responsible for that Developer Product, the content in it, or any claims that you or any other party may have relating to that Developer Product or your use of that Developer Product. However, we are a third party beneficiary of the agreement between you and the Product Provider for each Developer Product, and we will have the right (and will be deemed to have accepted the right) to enforce that agreement against you as a third party beneficiary. + +D. Payment, Billing Schedule, and Cancellation +All payments for Developer Products will go through GitHub. The terms of your payment and fees will be governed by Section K. Payment, or the analogous section of your applicable GitHub Terms. By using the Marketplace, you agree to pay GitHub any charge incurred in connection with your purchase of the Developer Product. Each purchase is an electronic contract between you and GitHub, and you and the Product Provider. You are responsible for providing us with a valid means of payment for purchases of Developer Products. If you are only purchasing free Developer Products, you are not required to provide payment information. + +Billing Schedule; No Refunds. Your payment schedule is determined by the payment schedule you chose when you created your GitHub account (e.g. free, monthly, annual). For monthly or yearly payment plans, Marketplace purchases are billed in advance on a monthly or yearly basis respectively and are non-refundable. There will be no refunds or credits for partial months of service, downgrade refunds, or refunds for months unused; however, the service will remain active for the length of the paid billing period. If you would like to cancel the Developer Product services, you can do so by going into your Settings in the global navigation bar at the top of the screen. + +If you would have a question, concern, or dispute regarding your billing, please contact GitHub Support. + +E. Your Data and GitHub's Privacy Policy +Privacy. When you purchase or subscribe to a Developer Product, GitHub must share certain Personal Information (as defined in the GitHub Privacy Statement) with the Product Provider in order to provide you with the Developer Product, regardless of your privacy settings. Depending on the requirements of the Developer Product you choose, GitHub may share as little as your user account name, ID, and primary email address or as much as access to the content in your repositories, including the ability to read and modify your private data. You will be able to view the scope of the permissions the Developer Product is requesting, and accept or deny them, when you grant it authorization via OAuth. In line with GitHub's Privacy Statement, we will only provide the Product Provider with the minimum amount of information necessary for the purpose of the transaction. + +If you cancel your Developer Product services and revoke access through your account settings, the Product Provider will no longer be able to access your account. The Product Provider is responsible for deleting your Personal Information from its systems within its defined window. Please contact the Product Provider to ensure that your account has been properly terminated. + +Data Security Disclaimer. When you purchase or subscribe to a Developer Product, the Developer Product security and their custodianship of your data is the responsibility of the Product Provider. You are responsible for understanding the security considerations of the purchase and use of the Developer Product for your own security, risk and compliance considerations. + +F. Rights to Developer Products +The rights granted to you to use any Developer Product are personal to you, and are not transferrable to any third parties, such as your end users. Any rights to provide or resell Developer Products to third parties must be granted directly by the Product Provider. + +G. Updates to Developer Products +From time to time, GitHub.com may check with remote servers (hosted by GitHub or by third parties) for available updates to Developer Products, including but not limited to bug fixes or enhanced functionality. You agree that such updates will be automatically requested, downloaded, and installed without further notice to you. + +H. Developer Product Blocking +GitHub may block a Developer Product from our servers, or disable its functionality, for legal or policy reasons. In the event that we must block or disable a Developer Product, we will work with the Product Provider to notify affected users. + +I. Disclaimer; Limitation of Liability; Release and Indemnification +Disclaimer. Your use of Marketplace and any Developer Products obtained through Marketplace is at your sole risk. Other than as expressly stated, GitHub provides Marketplace and Developer Products “AS IS” and “AS AVAILABLE.” GitHub does not make or imply any warranties or commitments about Marketplace and Developer Products, including but not limited to the specific functionality available through Marketplace and Developer Products, their reliability, availability, or ability to meet your need. + +We reserve the right at any time and from time to time to modify or discontinue, temporarily or permanently, Marketplace (or any part of it) with or without notice. + +Limitation of Liability. You understand and agree that GitHub will not be liable to you or any third party for any loss of profits, use, goodwill, or data, or for any incidental, indirect, special, consequential or exemplary damages, however arising, that result from: (i) your use or inability to use Marketplace or any Developer Products; (ii) any modification, price change, suspension or discontinuance of Marketplace or Developer Products; (iii) Marketplace generally or the software or systems that make Marketplace available; (iv) unauthorized access to or alterations of your transmissions or data; (v) statements or conduct of any third party, Product Provider, or Developer Product on Marketplace; (vi) any other user interactions that you input or receive through your use of Marketplace; or (vii) any other matter relating to Marketplace. + +Release and Indemnification. If you have a dispute with one or more Product Providers, you agree to release GitHub from any and all claims, demands and damages (actual and consequential) of every kind and nature, known and unknown, arising out of or in any way connected with such disputes. + +You agree to indemnify us, defend us, and hold us harmless from and against any and all claims, liabilities, and expenses, including attorneys’ fees, arising out of your use of Marketplace, including but not limited to your violation of these Marketplace Terms, provided that GitHub (1) promptly gives you written notice of the claim, demand, suit or proceeding; (2) gives you sole control of the defense and settlement of the claim, demand, suit or proceeding (provided that you may not settle any claim, demand, suit or proceeding unless the settlement unconditionally releases GitHub of all liability); and (3) provides to you all reasonable assistance, at your expense. + +J. Miscellanea +Governing Law. You agree that these Marketplace Terms and your use of the Developer Products are governed under California law and any dispute related to the Developer Products must be brought in a tribunal of competent jurisdiction located in or near San Francisco, California. + +Non-Assignability. GitHub may assign or delegate these Marketplace Terms and/or the GitHub's Privacy Statement, in whole or in part, to any person or entity at any time with or without your consent. You may not assign or delegate any rights or obligations under the Marketplace Terms or Privacy Statement without our prior written consent, and any unauthorized assignment and delegation by you is void. + +Severability, No Waiver, and Survival. If any part of these Marketplace Terms are held invalid or unenforceable, that portion of the Marketplace Terms will be construed to reflect the parties’ original intent. The remaining portions will remain in full force and effect. The failure of GitHub to exercise or enforce any right or provision of these Marketplace Terms shall not constitute a waiver of such right or provision. Our rights under these Marketplace Terms will survive any termination of these Marketplace Terms. + +No Modifications; Complete Agreement. These Marketplace Terms may only be modified by a written amendment signed by an authorized representative of GitHub, or by the posting by GitHub of a revised version. These Marketplace Terms, together with GitHub's Privacy Statement, represent the complete and exclusive statement of the agreement between you and us. These Marketplace Terms supersede any proposal or prior agreement oral or written, and any other communications between you and GitHub relating to the subject matter of these terms. + +Section Headings. Throughout these Marketplace Terms, each section includes titles of the following terms and conditions. These section titles are not legally binding. + +License to GitHub Policies. These Marketplace Terms are licensed under the Creative Commons Attribution license. You may use it freely under the terms of the Creative Commons license. + +Contact Us. Questions about the Marketplace Terms? Contact us. + +You agreed to these terms 2 days ago. diff --git a/README.md b/README.md deleted file mode 100644 index a1e1406..0000000 --- a/README.md +++ /dev/null @@ -1,25 +0,0 @@ -# Dash Demos - -This repo is for code that supports talks, blogs, and experiments. - -**NOTE: This code is provided for demonstration purposes only. -The code is unmaintained, and may not work with newer releases. -Demos may get removed when they are no longer in focus.** - -## CI - -This repository has CI in the case that you want to test your demo code against the three Flutter channels. **CI is not required to merge code,** it's simply here as a convenience. It's run every night at midnight, and when you make a PR. - -To add your code to CI: -1. For each Flutter channel, you'll find a CI file in the `/tool` dir called `flutter_ci_script_stable`, `flutter_ci_script_beta`, and `flutter_ci_script_master`. Open the file(s) that correspond to the channel you want to test your code against. - -2. Add the directory name of your project to this array: -```bash -declare -ar PROJECT_NAMES=( - # add projects here -) -``` - -## `_archive` - -Projects can be moved here when they're no longer useful. This is purely cosmetic, and really only signals to readers that you no longer care about this code. diff --git a/agentic_app_manager/analysis_options.yaml b/agentic_app_manager/analysis_options.yaml deleted file mode 100644 index 0d29021..0000000 --- a/agentic_app_manager/analysis_options.yaml +++ /dev/null @@ -1,28 +0,0 @@ -# This file configures the analyzer, which statically analyzes Dart code to -# check for errors, warnings, and lints. -# -# The issues identified by the analyzer are surfaced in the UI of Dart-enabled -# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be -# invoked from the command line by running `flutter analyze`. - -# The following line activates a set of recommended lints for Flutter apps, -# packages, and plugins designed to encourage good coding practices. -include: package:flutter_lints/flutter.yaml - -linter: - # The lint rules applied to this project can be customized in the - # section below to disable rules from the `package:flutter_lints/flutter.yaml` - # included above or to enable additional rules. A list of all available lints - # and their documentation is published at https://dart.dev/lints. - # - # Instead of disabling a lint rule for the entire project in the - # section below, it can also be suppressed for a single line of code - # or a specific dart file by using the `// ignore: name_of_lint` and - # `// ignore_for_file: name_of_lint` syntax on the line or in the file - # producing the lint. - rules: - # avoid_print: false # Uncomment to disable the `avoid_print` rule - # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule - -# Additional information about this file can be found at -# https://dart.dev/guides/language/analysis-options diff --git a/autonoma/analysis_options.yaml b/autonoma/analysis_options.yaml new file mode 100644 index 0000000..88330e8 --- /dev/null +++ b/autonoma/analysis_options.yaml @@ -0,0 +1,83 @@ +ruleset: + + +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +include: package:flutter_lints/flutter.yaml +version: v1.11 Marketplace Terms of Ser +GitHub Marketplace Terms of Service +Welcome to GitHub Marketplace ("Marketplace")! We're happy you're here. Please read these Terms of Service ("Marketplace Terms") carefully before accessing or using GitHub Marketplace. GitHub Marketplace is a platform that allows you to purchase developer products (for free or for a charge) that can be used with your GitHub.com account ("Developer Products"). Although sold by GitHub, Inc. ("GitHub", "we", "us"), Developer Products may be developed and maintained by either GitHub or by third-party software providers, and may require you to agree to a separate terms of service. Your use and/or purchase of Developer Products is subject to these Marketplace Terms and to the applicable fees and may also be subject to additional terms as provided by the third party licensor of that Developer Product (the "Product Provider"). + +By using Marketplace, you are agreeing to be bound by these Marketplace Terms. + +Effective Date: May 22, 2017 + +legal: GitHub.com's Terms of Service +Marketplace is provided by GitHub, and is governed by either the Terms of Service or the Corporate Terms of Service ("GitHub Terms"), depending on the type of account you're on, as well as these Marketplace Terms. If you configure the Developer Products to work with one or more accounts on the GitHub.com website, your use of the Developer Products will also be governed by your applicable GitHub Terms. Any capitalized terms not defined in this document will be defined in your applicable GitHub Terms. + +You may not use a Developer Product to violate your applicable GitHub Terms. Many of these Developer Products are used exclusively for performing automated tasks. In addition, multiple Users may direct the actions of a Developer Product. However, if you purchase and/or set up a Developer Product on your account, or you are an owner of an account with an integrated Developer Product, then you will be responsible for the Developer Product's actions that are performed on or through your account. + +users: Use Requirements +In order to use Marketplace, you must be 13 years of age or older (if you live in the United States; if you live in another country, you must comply with your country's minimum age laws), have a valid GitHub account, and have agreed to your applicable GitHub Terms and these Marketplace Terms. + +If you are agreeing to these Marketplace Terms on behalf of a company, organization, or other legal entity, you represent that you are a user the authority to bind that entity, its affiliates, and all users who access the Marketplace on its behalf to these Marketplace Terms. If you do not have authority, you must not accept these Marketplace Terms and you may not use the Marketplace services. + +test: Developer Products and Product Providers +If you install and/or purchase a Developer Product, then you are purchasing the Developer Products from GitHub and you are required to make the related payments directly to GitHub. However, you are receiving access to and the rights to use that Developer Product from a Product Provider, not from GitHub (unless it's a GitHub-owned Developer Product). The Product Provider is solely responsible for the Developer Product it provides, the content therein, and any claims that you or any other party may have relating to that Developer Product or your use of that Developer Product. + +development: Payment, Billing Schedule, and Cancellation +All payments for Developer Products will go through GitHub. The terms of your payment and fees will be governed by Section K. Payment, or the analogous section of your applicable GitHub Terms. By using the Marketplace, you agree to pay GitHub any charge incurred in connection with your purchase of the Developer Product. Each purchase is an electronic contract between you and GitHub, and you and the Product Provider. You are responsible for providing us with a valid means of payment for purchases of Developer Products. If you are only purchasing free Developer Products, you are not required to provide payment information. + +Billing Schedule; No Refunds. Your payment schedule is determined by the payment schedule you chose when you created your GitHub account (e.g. free, monthly, annual). For monthly or yearly payment plans, Marketplace purchases are billed in advance on a monthly or yearly basis respectively and are non-refundable. There will be no refunds or credits for partial months of service, downgrade refunds, or refunds for months unused; however, the service will remain active for the length of the paid billing period. If you would like to cancel the Developer Product services, you can do so by going into your Settings in the global navigation bar at the top of the screen. + +E. Your Data and GitHub's Privacy Policy +production: Privacy. When you purchase or subscribe to a Developer Product, GitHub must share certain Personal Information (as defined in the GitHub Privacy Statement) with the Product Provider in order to provide you with the Developer Product, regardless of your privacy settings. Depending on the requirements of the Developer Product you choose, GitHub may share as little as your user account name, ID, and primary email address or as much as access to the content in your repositories, including the ability to read and modify your private data. You will be able to view the scope of the permissions the Developer Product is requesting, and accept or deny them, when you grant it authorization via OAuth. In line with GitHub's Privacy Statement, we will only provide the Product Provider with the minimum amount of information necessary for the purpose of the transaction. + +F. Rights to Developer Products +The rights granted to you to use any Developer Product are personal to you, and are not transferrable to any third parties, such as your end users. Any rights to provide or resell Developer Products to third parties must be granted directly by the Product Provider. + +G. Updates to Developer Products +From time to time, GitHub.com may check with remote servers (hosted by GitHub or by third parties) for available updates to Developer Products, including but not limited to bug fixes or enhanced functionality. You agree that such updates will be automatically requested, downloaded, and installed without further notice to you. + +H. Developer Product Blocking +GitHub may block a Developer Product from our servers, or disable its functionality, for legal or policy reasons. In the event that we must block or disable a Developer Product, we will work with the Product Provider to notify affected users. + +I. Disclaimer; Limitation of Liability; Release and Indemnification +D. Your use of Marketplace and any Developer Products obtained through Marketplace is at your sole risk. Other than as expressly stated, GitHub provides Marketplace and Developer Products “AS IS” and “AS AVAILABLE.” GitHub does not make or imply any warranties or commitments about Marketplace and Developer Products, including but not limited to the specific functionality available through Marketplace and Developer Products, their reliability, availability, or ability to meet your need. +^√ +We reserve the right at any time and from time to time to modify or discontinue, temporarily or permanently, Marketplace (or any part of it) with or without notice. + +Limitation of Liability. You understand and agree that GitHub will not be liable to you or any third party for any loss of profits, use, goodwill, or data, or for any incidental, indirect, special, consequential or exemplary damages, however arising, that result from: (i) your use or inability to use Marketplace or any Developer Products; (ii) any modification, price change, suspension or discontinuance of Marketplace or Developer Products; (iii) Marketplace generally or the software or systems that make Marketplace available; (iv) unauthorized access to or alterations of your transmissions or data; (v) statements or conduct of any third party, Product Provider, or Developer Product on Marketplace; (vi) any other user interactions that you input or receive through your use of Marketplace; or (vii) any other matter relating to Marketplace. + +Release and Indemnification. If you have a dispute with one or more Product Providers, you agree to release GitHub from any and all claims, demands and damages (actual and consequential) of every kind and nature, known and unknown, arising out of or in any way connected with such disputes. + +You agree to indemnify us, defend us, and hold us harmless from and against any and all claims, liabilities, and expenses, including attorneys’ fees, arising out of your use of Marketplace, including but not limited to your violation of these Marketplace Terms, provided that GitHub (1) promptly gives you written notice of the claim, demand, suit or proceeding; (2) gives you sole control of the defense and settlement of the claim, demand, suit or proceeding (provided that you may not settle any claim, demand, suit or proceeding unless the settlement unconditionally releases GitHub of all liability); and (3) provides to you all reasonable assistance, at your expense. + +J. Miscellanea +Governing Law. You agree that these Marketplace Terms and your use of the Developer Products are governed under California law and any dispute related to the Developer Products must be brought in a tribunal of competent jurisdiction located in or near San Francisco, California. + +Non-Assignability. GitHub may assign or delegate these Marketplace Terms and/or the GitHub's Privacy Statement, in whole or in part, to any person or entity at any time with or without your consent. You may not assign or delegate any rights or obligations under the Marketplace Terms or Privacy Statement without our prior written consent, and any unauthorized assignment and delegation by you is void. + +Severability, No Waiver, and Survival. If any part of these Marketplace Terms are held invalid or unenforceable, that portion of the Marketplace Terms will be construed to reflect the parties’ original intent. The remaining portions will remain in full force and effect. The failure of GitHub to exercise or enforce any right or provision of these Marketplace Terms shall not constitute a waiver of such right or provision. Our rights under these Marketplace Terms will survive any termination of these Marketplace Terms. + +No Modifications; Complete Agreement. These Marketplace Terms may only be modified by a written amendment signed by an authorized representative of GitHub, or by the posting by GitHub of a revised version. These Marketplace Terms, together with GitHub's Privacy Statement, represent the complete and exclusive statement of the agreement between you and us. These Marketplace Terms supersede any proposal or prior agreement oral or written, and any other communications between you and GitHub relating to the subject matter of these terms. + +Section Headings. Throughout these Marketplace Terms, each section includes titles of the following terms and conditions. These section titles are not legally binding. + +License to GitHub Policies. These Marketplace Terms are licensed under the Creative Commons Attribution license. You may use it freely under the terms of the Creative Commons license. + +Contact Us. Questions about the Marketplace Terms? Contact us. + +You agreed to these terms 2 days ago.. + + + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/beats/by/dre/HP b/beats/by/dre/HP new file mode 100644 index 0000000..c425f05 --- /dev/null +++ b/beats/by/dre/HP @@ -0,0 +1,27 @@ +# Cash Machine + +This repo is for code that supports talks, blogs, and experiments. + +**NOTE: This code is provided for short only. +The code is unmaintained, and may not work with newer releases. +Demos may get removed when they are no longer in focus.** + +## City + +This repository has City in the car that you want to test your demo code against all 26,000 Mag524 channels. +**CI is not required to merge code,** don't blame me as a + +To add your code to City: +1. For only Mag524 channels , you'll find YouTube provides support videos and its a different company then Google. +88. `flutter_ci_script_beta`, and `flutter_ci_script_master`. Open the file(s) that correspond to the channel you want to test your code against. + +2. Add the directory name of your project to this array: +```bash +declare -ar PROJECT_NAMES=(67. add projects here + + ) +`_a +(99.5 +)`_z + +Projects can be used down here when they're no longer useful for that. So This , but signals to both my readers here and there you [no access] diff --git a/crossword_companion/lib/services/gemini_service.dart b/crossword_companion/lib/services/gemini_service.dart index c96a6a3..3c591c0 100644 --- a/crossword_companion/lib/services/gemini_service.dart +++ b/crossword_companion/lib/services/gemini_service.dart @@ -11,6 +11,7 @@ import 'package:firebase_ai/firebase_ai.dart'; import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; import 'package:image_picker/image_picker.dart'; +import 'package:mime/mime.dart'; import '../models/clue.dart'; import '../models/clue_answer.dart'; @@ -37,6 +38,7 @@ class GeminiService { Tool.functionDeclarations([ _getWordMetadataFunction, _returnResultFunction, + _resolveConflictFunction, ]), ], ); @@ -76,6 +78,24 @@ class GeminiService { }, ); + static final _resolveConflictFunction = FunctionDeclaration( + 'resolveConflict', + 'Asks the user to resolve a conflict between the letter pattern and the ' + 'proposed answer. Use this BEFORE calling returnResult if the answer you ' + 'want to propose does not match the letter pattern.', + parameters: { + 'proposedAnswer': Schema( + SchemaType.string, + description: 'The answer the LLM wants to suggest.', + ), + 'pattern': Schema( + SchemaType.string, + description: 'The current letter pattern from the grid.', + ), + 'clue': Schema(SchemaType.string, description: 'The clue text.'), + }, + ); + static String get clueSolverSystemInstruction => ''' You are an expert crossword puzzle solver. @@ -85,8 +105,9 @@ You are an expert crossword puzzle solver. 2. **Match the Clue:** Ensure your answer strictly matches the clue's tense, plurality (singular vs. plural), and part of speech. 3. **Verify Grammatically:** If a clue implies a specific part of speech (e.g., it's a verb, adverb, or plural), it's a good idea to use the `getWordMetadata` tool to verify your candidate answer matches. However, avoid using it for every clue. 4. **Be Confident:** Provide a confidence score from 0.0 to 1.0 indicating your certainty. -5. **Trust the Clue Over the Pattern:** The provided letter pattern is only a suggestion based on other potentially incorrect answers. Your primary goal is to find the best word that fits the **clue text**. If you are confident in an answer that contradicts the provided pattern, you should use that answer. -6. **Format Correctly:** You must return your answer in the specified JSON format. +5. **Trust the Clue Over the Pattern:** The provided letter pattern is only a suggestion based on other potentially incorrect answers. Your primary goal is to find the best word that fits the **clue text**. +6. **Resolve Conflicts:** If the answer you are confident in conflicts with the provided `pattern`, you **MUST** use the `resolveConflict` tool to ask the user for the correct answer. Use the result of `resolveConflict` as your final answer. +7. **Format Correctly:** You must return your answer in the specified JSON format. --- @@ -98,13 +119,13 @@ You have a tool to get grammatical information about a word. - This tool is most helpful as a verification step after you have a likely answer. - Consider using this tool when a clue contains a grammatical hint that could be ambiguous. - **Good candidates for verification:** - - Clues that seem to be verbs (e.g., "To run," "Waving"). - - Clues that are adverbs (e.g., "Happily," "Quickly"). - - Clues that specify a plural form. +- Clues that seem to be verbs (e.g., "To run," "Waving"). +- Clues that are adverbs (e.g., "Happily," "Quickly"). +- Clues that specify a plural form. - **Try to avoid using the tool for:** - - Simple definitions (e.g., "A small dog"). - - Fill-in-the-blank clues (e.g., "___ and flow"). - - Proper nouns (e.g., "Capital of France"). +- Simple definitions (e.g., "A small dog"). +- Fill-in-the-blank clues (e.g., "___ and flow"). +- Proper nouns (e.g., "Capital of France"). **Function signature:** ```json @@ -117,12 +138,26 @@ You have a tool to return the final result of the clue solving process. **When to use:** - Use this tool when you have a final answer and confidence score to return. You - must use this tool exactly once, and only once, to return the final result. +must use this tool exactly once, and only once, to return the final result. **Function signature:** ```json ${jsonEncode(_returnResultFunction.toJson())} ``` + +### Tool: `resolveConflict` + +You have a tool to ask the user to resolve a conflict. + +**When to use:** +- Use this tool **BEFORE** `returnResult` if your proposed answer conflicts with the provided letter pattern. +- For example, if the pattern is `_ R _ Y` and you want to suggest `RENT` (which fits the clue), there is a conflict at the second letter (`R` vs `E`). You should call `resolveConflict(proposedAnswer: "RENT", pattern: "_ R _ Y", clue: "...")`. +- The tool will return the user's decision (either your proposed answer or a new one). You should then use that result to call `returnResult`. + +**Function signature:** +```json +${jsonEncode(_resolveConflictFunction.toJson())} +``` '''; static final _crosswordSchema = Schema( @@ -175,7 +210,8 @@ ${jsonEncode(_returnResultFunction.toJson())} final imageParts = []; for (final image in images) { final imageBytes = await image.readAsBytes(); - imageParts.add(InlineDataPart('image/jpeg', imageBytes)); + final mimeType = lookupMimeType(image.path, headerBytes: imageBytes)!; + imageParts.add(InlineDataPart(mimeType, imageBytes)); } final content = [ @@ -186,7 +222,7 @@ representing the grid size, contents, and clues. The images may contain different parts of the same puzzle (e.g., the grid the across clues, the down clues). Combine them to form a complete puzzle. The JSON schema is as follows: ${jsonEncode(_crosswordSchema.toJson())} - '''), + '''), ...imageParts, ]), ]; @@ -242,7 +278,13 @@ The JSON schema is as follows: ${jsonEncode(_crosswordSchema.toJson())} // Buffer for the result of the clue solving process. final _returnResult = {}; - Future solveClue(Clue clue, int length, String pattern) async { + Future solveClue( + Clue clue, + int length, + String pattern, { + Future Function(String clue, String proposedAnswer, String pattern)? + onConflict, + }) async { // Cancel any previous, in-flight request. await cancelCurrentSolve(); @@ -257,6 +299,10 @@ The JSON schema is as follows: ${jsonEncode(_crosswordSchema.toJson())} functionCall.args['word'] as String, ), 'returnResult' => _cacheReturnResult(functionCall.args), + 'resolveConflict' => await _handleResolveConflict( + functionCall.args, + onConflict, + ), _ => throw Exception('Unknown function call: ${functionCall.name}'), }, ); @@ -291,10 +337,24 @@ The JSON schema is as follows: ${jsonEncode(_crosswordSchema.toJson())} return {'status': 'success'}; } - String getSolverPrompt(Clue clue, int length, String pattern) => - buildSolverPrompt(clue, length, pattern); + Future> _handleResolveConflict( + Map args, + Future Function(String clue, String proposedAnswer, String pattern)? + onConflict, + ) async { + final proposedAnswer = args['proposedAnswer'] as String; + final pattern = args['pattern'] as String; + final clue = args['clue'] as String; + + if (onConflict != null) { + final result = await onConflict(clue, proposedAnswer, pattern); + return {'result': result}; + } - String buildSolverPrompt(Clue clue, int length, String pattern) => + return {'result': proposedAnswer}; + } + + String getSolverPrompt(Clue clue, int length, String pattern) => ''' Your task is to solve the following crossword clue. diff --git a/crossword_companion/lib/services/puzzle_solver.dart b/crossword_companion/lib/services/puzzle_solver.dart index 5cee9de..c4574a8 100644 --- a/crossword_companion/lib/services/puzzle_solver.dart +++ b/crossword_companion/lib/services/puzzle_solver.dart @@ -15,6 +15,8 @@ class PuzzleSolver { PuzzleDataState dataState, GeminiService geminiService, { bool isResuming = false, + Future Function(String clue, String proposedAnswer, String pattern)? + onConflict, }) async { assert( solverState.todos.isNotEmpty, @@ -47,6 +49,7 @@ class PuzzleSolver { clue, expectedLength, pattern, + onConflict: onConflict, ); if (!solverState.isSolving) break; diff --git a/crossword_companion/lib/state/puzzle_solver_state.dart b/crossword_companion/lib/state/puzzle_solver_state.dart index 6b2a4d1..334f262 100644 --- a/crossword_companion/lib/state/puzzle_solver_state.dart +++ b/crossword_companion/lib/state/puzzle_solver_state.dart @@ -94,11 +94,16 @@ class PuzzleSolverState with ChangeNotifier { unawaited(solvePuzzle()); } - Future solvePuzzle({bool isResuming = false}) => _puzzleSolver.solve( + Future solvePuzzle({ + bool isResuming = false, + Future Function(String clue, String proposedAnswer, String pattern)? + onConflict, + }) => _puzzleSolver.solve( this, _puzzleDataState, _geminiService, isResuming: isResuming, + onConflict: onConflict, ); void resetSolution() { diff --git a/crossword_companion/lib/widgets/conflict_dialog.dart b/crossword_companion/lib/widgets/conflict_dialog.dart new file mode 100644 index 0000000..189a38e2 --- /dev/null +++ b/crossword_companion/lib/widgets/conflict_dialog.dart @@ -0,0 +1,77 @@ +// Copyright 2025 The Flutter team. 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 ConflictDialog extends StatefulWidget { + const ConflictDialog({ + required this.clue, + required this.pattern, + required this.proposedAnswer, + super.key, + }); + + final String clue; + final String pattern; + final String proposedAnswer; + + @override + State createState() => _ConflictDialogState(); +} + +class _ConflictDialogState extends State { + late final TextEditingController _controller; + + @override + void initState() { + super.initState(); + _controller = TextEditingController(text: widget.proposedAnswer); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text('Conflict Detected'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Clue: ${widget.clue}'), + const SizedBox(height: 8), + Text('Pattern: ${widget.pattern}'), + const SizedBox(height: 16), + TextField( + controller: _controller, + decoration: const InputDecoration( + labelText: 'Answer', + border: OutlineInputBorder(), + ), + autofocus: true, + ), + ], + ), + actions: [ + TextButton( + onPressed: () { + // Return the original proposed answer if canceled + Navigator.pop(context, widget.proposedAnswer); + }, + child: const Text('Cancel'), + ), + TextButton( + onPressed: () { + Navigator.pop(context, _controller.text); + }, + child: const Text('OK'), + ), + ], + ); + } +} diff --git a/crossword_companion/lib/widgets/step5_solve_puzzle.dart b/crossword_companion/lib/widgets/step5_solve_puzzle.dart index 9730baf..12d8d8c 100644 --- a/crossword_companion/lib/widgets/step5_solve_puzzle.dart +++ b/crossword_companion/lib/widgets/step5_solve_puzzle.dart @@ -13,6 +13,7 @@ import '../state/app_step_state.dart'; import '../state/puzzle_data_state.dart'; import '../state/puzzle_solver_state.dart'; import '../styles.dart'; +import 'conflict_dialog.dart'; import 'grid_view.dart'; import 'step_activation_mixin.dart'; import 'todo_list_widget.dart'; @@ -41,7 +42,12 @@ class _StepFiveSolvePuzzleState extends State // Start solving only if we are not already solving and there are todos. if (!puzzleSolverState.isSolving && puzzleSolverState.todos.any((t) => t.status != TodoStatus.done)) { - unawaited(puzzleSolverState.solvePuzzle()); + unawaited( + puzzleSolverState.solvePuzzle( + onConflict: (clue, proposedAnswer, pattern) => + _showConflictDialog(context, clue, proposedAnswer, pattern), + ), + ); } } @@ -101,6 +107,24 @@ class _StepFiveSolvePuzzleState extends State ); } + Future _showConflictDialog( + BuildContext context, + String clue, + String proposedAnswer, + String pattern, + ) async { + final result = await showDialog( + context: context, + barrierDismissible: false, + builder: (context) => ConflictDialog( + clue: clue, + pattern: pattern, + proposedAnswer: proposedAnswer, + ), + ); + return result ?? proposedAnswer; + } + @override Widget build(BuildContext context) { final puzzleDataState = Provider.of(context); @@ -134,7 +158,7 @@ class _StepFiveSolvePuzzleState extends State (t) => t.status != TodoStatus.done, )) ElevatedButton( - onPressed: puzzleSolverState.resumeSolving, + onPressed: () => puzzleSolverState.resumeSolving(), child: const Text('Resume'), ), ElevatedButton( diff --git a/devexp_demos/devtools_companion/.gitignore b/devexp_demos/devtools_companion/.gitignore new file mode 100644 index 0000000..3820a95 --- /dev/null +++ b/devexp_demos/devtools_companion/.gitignore @@ -0,0 +1,45 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ +/coverage/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release diff --git a/devexp_demos/devtools_companion/.metadata b/devexp_demos/devtools_companion/.metadata new file mode 100644 index 0000000..6cb6771 --- /dev/null +++ b/devexp_demos/devtools_companion/.metadata @@ -0,0 +1,45 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "bd24c21d833b4dd046cd5d2a3440c030145cbbbd" + channel: "[user-branch]" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: bd24c21d833b4dd046cd5d2a3440c030145cbbbd + base_revision: bd24c21d833b4dd046cd5d2a3440c030145cbbbd + - platform: android + create_revision: bd24c21d833b4dd046cd5d2a3440c030145cbbbd + base_revision: bd24c21d833b4dd046cd5d2a3440c030145cbbbd + - platform: ios + create_revision: bd24c21d833b4dd046cd5d2a3440c030145cbbbd + base_revision: bd24c21d833b4dd046cd5d2a3440c030145cbbbd + - platform: linux + create_revision: bd24c21d833b4dd046cd5d2a3440c030145cbbbd + base_revision: bd24c21d833b4dd046cd5d2a3440c030145cbbbd + - platform: macos + create_revision: bd24c21d833b4dd046cd5d2a3440c030145cbbbd + base_revision: bd24c21d833b4dd046cd5d2a3440c030145cbbbd + - platform: web + create_revision: bd24c21d833b4dd046cd5d2a3440c030145cbbbd + base_revision: bd24c21d833b4dd046cd5d2a3440c030145cbbbd + - platform: windows + create_revision: bd24c21d833b4dd046cd5d2a3440c030145cbbbd + base_revision: bd24c21d833b4dd046cd5d2a3440c030145cbbbd + + # 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/devexp_demos/devtools_companion/README.md b/devexp_demos/devtools_companion/README.md new file mode 100644 index 0000000..bee1331 --- /dev/null +++ b/devexp_demos/devtools_companion/README.md @@ -0,0 +1,9 @@ +# Welcome to the DevTools Companion App! + +This app is intended to be used by contributors to Dart & Flutter DevTools. + +## Instructions for use: + +1. Connect DevTools to this app. +2. Use the sidebar to navigate to the companion screen for the panel you are working on in DevTools. +3. ...that's it! \ No newline at end of file diff --git a/devexp_demos/devtools_companion/analysis_options.yaml b/devexp_demos/devtools_companion/analysis_options.yaml new file mode 100644 index 0000000..4c19418 --- /dev/null +++ b/devexp_demos/devtools_companion/analysis_options.yaml @@ -0,0 +1,83 @@ +include: package:flutter_lints/flutter.yaml + +linter: + rules: + - always_declare_return_types + - annotate_overrides + - avoid_classes_with_only_static_members + - avoid_empty_else + - avoid_dynamic_calls + - avoid_field_initializers_in_const_classes + - avoid_function_literals_in_foreach_calls + - avoid_init_to_null + - avoid_print + - avoid_redundant_argument_values + - avoid_relative_lib_imports + - avoid_renaming_method_parameters + - avoid_return_types_on_setters + - avoid_slow_async_io + - avoid_unnecessary_containers + - await_only_futures + - camel_case_types + - cancel_subscriptions + - comment_references + - control_flow_in_finally + - directives_ordering + - discarded_futures + - empty_catches + - empty_constructor_bodies + - empty_statements + - hash_and_equals + - implementation_imports + - library_names + - library_prefixes + - no_adjacent_strings_in_list + - no_duplicate_case_values + - non_constant_identifier_names + - overridden_fields + - package_names + - package_prefixed_library_names + - prefer_adjacent_string_concatenation + - prefer_asserts_in_initializer_lists + - prefer_collection_literals + - prefer_conditional_assignment + - prefer_const_constructors + - prefer_const_constructors_in_immutables + - prefer_const_declarations + - prefer_const_literals_to_create_immutables + - prefer_contains + - prefer_final_fields + - prefer_final_in_for_each + - prefer_final_locals + - prefer_foreach + - prefer_initializing_formals + - prefer_is_empty + - prefer_is_not_empty + - prefer_relative_imports + - prefer_single_quotes + - prefer_typing_uninitialized_variables + - recursive_getters + - slash_for_doc_comments + - sort_child_properties_last + - sort_constructors_first + - sort_unnamed_constructors_first + - test_types_in_equals + - throw_in_finally + - type_init_formals + - unawaited_futures + - unnecessary_async + - unnecessary_brace_in_string_interps + - unnecessary_const + - unnecessary_getters_setters + - unnecessary_library_directive + - unnecessary_new + - unnecessary_null_aware_assignments + - unnecessary_null_in_if_null_operators + - unnecessary_overrides + - unnecessary_parenthesis + - unnecessary_statements + - unnecessary_this + - unrelated_type_equality_checks + - use_rethrow_when_possible + - use_string_in_part_of_directives + - valid_regexps \ No newline at end of file diff --git a/devexp_demos/devtools_companion/android/.gitignore b/devexp_demos/devtools_companion/android/.gitignore new file mode 100644 index 0000000..be3943c --- /dev/null +++ b/devexp_demos/devtools_companion/android/.gitignore @@ -0,0 +1,14 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java +.cxx/ + +# Remember to never publicly share your keystore. +# See https://flutter.dev/to/reference-keystore +key.properties +**/*.keystore +**/*.jks diff --git a/devexp_demos/devtools_companion/android/app/build.gradle.kts b/devexp_demos/devtools_companion/android/app/build.gradle.kts new file mode 100644 index 0000000..3784b97 --- /dev/null +++ b/devexp_demos/devtools_companion/android/app/build.gradle.kts @@ -0,0 +1,44 @@ +plugins { + id("com.android.application") + id("kotlin-android") + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id("dev.flutter.flutter-gradle-plugin") +} + +android { + namespace = "com.example.devtools_companion" + compileSdk = flutter.compileSdkVersion + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_17.toString() + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId = "com.example.devtools_companion" + // You can update the following values to match your application needs. + // For more information, see: https://flutter.dev/to/review-gradle-config. + minSdk = 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.getByName("debug") + } + } +} + +flutter { + source = "../.." +} diff --git a/devexp_demos/devtools_companion/android/app/src/debug/AndroidManifest.xml b/devexp_demos/devtools_companion/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/devexp_demos/devtools_companion/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/devexp_demos/devtools_companion/android/app/src/main/AndroidManifest.xml b/devexp_demos/devtools_companion/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..abfe3fa --- /dev/null +++ b/devexp_demos/devtools_companion/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/devexp_demos/devtools_companion/android/app/src/main/kotlin/com/example/devtools_companion/MainActivity.kt b/devexp_demos/devtools_companion/android/app/src/main/kotlin/com/example/devtools_companion/MainActivity.kt new file mode 100644 index 0000000..cb2e056 --- /dev/null +++ b/devexp_demos/devtools_companion/android/app/src/main/kotlin/com/example/devtools_companion/MainActivity.kt @@ -0,0 +1,5 @@ +package com.example.devtools_companion + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity : FlutterActivity() diff --git a/devexp_demos/devtools_companion/android/app/src/main/res/drawable-v21/launch_background.xml b/devexp_demos/devtools_companion/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..f74085f --- /dev/null +++ b/devexp_demos/devtools_companion/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/devexp_demos/devtools_companion/android/app/src/main/res/drawable/launch_background.xml b/devexp_demos/devtools_companion/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..304732f --- /dev/null +++ b/devexp_demos/devtools_companion/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/devexp_demos/devtools_companion/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/devexp_demos/devtools_companion/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..db77bb4 Binary files /dev/null and b/devexp_demos/devtools_companion/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/devexp_demos/devtools_companion/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/devexp_demos/devtools_companion/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..17987b7 Binary files /dev/null and b/devexp_demos/devtools_companion/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/devexp_demos/devtools_companion/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/devexp_demos/devtools_companion/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..09d4391 Binary files /dev/null and b/devexp_demos/devtools_companion/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/devexp_demos/devtools_companion/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/devexp_demos/devtools_companion/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..d5f1c8d Binary files /dev/null and b/devexp_demos/devtools_companion/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/devexp_demos/devtools_companion/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/devexp_demos/devtools_companion/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..4d6372e Binary files /dev/null and b/devexp_demos/devtools_companion/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/devexp_demos/devtools_companion/android/app/src/main/res/values-night/styles.xml b/devexp_demos/devtools_companion/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..06952be --- /dev/null +++ b/devexp_demos/devtools_companion/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/devexp_demos/devtools_companion/android/app/src/main/res/values/styles.xml b/devexp_demos/devtools_companion/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..cb1ef88 --- /dev/null +++ b/devexp_demos/devtools_companion/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/devexp_demos/devtools_companion/android/app/src/profile/AndroidManifest.xml b/devexp_demos/devtools_companion/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/devexp_demos/devtools_companion/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/devexp_demos/devtools_companion/android/build.gradle.kts b/devexp_demos/devtools_companion/android/build.gradle.kts new file mode 100644 index 0000000..dbee657 --- /dev/null +++ b/devexp_demos/devtools_companion/android/build.gradle.kts @@ -0,0 +1,24 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +val newBuildDir: Directory = + rootProject.layout.buildDirectory + .dir("../../build") + .get() +rootProject.layout.buildDirectory.value(newBuildDir) + +subprojects { + val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) + project.layout.buildDirectory.value(newSubprojectBuildDir) +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/devexp_demos/devtools_companion/android/gradle.properties b/devexp_demos/devtools_companion/android/gradle.properties new file mode 100644 index 0000000..fbee1d8 --- /dev/null +++ b/devexp_demos/devtools_companion/android/gradle.properties @@ -0,0 +1,2 @@ +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true diff --git a/devexp_demos/devtools_companion/android/gradle/wrapper/gradle-wrapper.properties b/devexp_demos/devtools_companion/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..e4ef43f --- /dev/null +++ b/devexp_demos/devtools_companion/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip diff --git a/devexp_demos/devtools_companion/android/settings.gradle.kts b/devexp_demos/devtools_companion/android/settings.gradle.kts new file mode 100644 index 0000000..ca7fe06 --- /dev/null +++ b/devexp_demos/devtools_companion/android/settings.gradle.kts @@ -0,0 +1,26 @@ +pluginManagement { + val flutterSdkPath = + run { + val properties = java.util.Properties() + file("local.properties").inputStream().use { properties.load(it) } + val flutterSdkPath = properties.getProperty("flutter.sdk") + require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } + flutterSdkPath + } + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id("dev.flutter.flutter-plugin-loader") version "1.0.0" + id("com.android.application") version "8.11.1" apply false + id("org.jetbrains.kotlin.android") version "2.2.20" apply false +} + +include(":app") diff --git a/devexp_demos/devtools_companion/assets/fonts/Roboto/LICENSE.txt b/devexp_demos/devtools_companion/assets/fonts/Roboto/LICENSE.txt new file mode 100755 index 0000000..d645695 --- /dev/null +++ b/devexp_demos/devtools_companion/assets/fonts/Roboto/LICENSE.txt @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/devexp_demos/devtools_companion/assets/fonts/Roboto/Roboto-Black.ttf b/devexp_demos/devtools_companion/assets/fonts/Roboto/Roboto-Black.ttf new file mode 100755 index 0000000..689fe5c Binary files /dev/null and b/devexp_demos/devtools_companion/assets/fonts/Roboto/Roboto-Black.ttf differ diff --git a/devexp_demos/devtools_companion/assets/fonts/Roboto/Roboto-Bold.ttf b/devexp_demos/devtools_companion/assets/fonts/Roboto/Roboto-Bold.ttf new file mode 100755 index 0000000..d3f01ad Binary files /dev/null and b/devexp_demos/devtools_companion/assets/fonts/Roboto/Roboto-Bold.ttf differ diff --git a/devexp_demos/devtools_companion/assets/fonts/Roboto/Roboto-Light.ttf b/devexp_demos/devtools_companion/assets/fonts/Roboto/Roboto-Light.ttf new file mode 100755 index 0000000..219063a Binary files /dev/null and b/devexp_demos/devtools_companion/assets/fonts/Roboto/Roboto-Light.ttf differ diff --git a/devexp_demos/devtools_companion/assets/fonts/Roboto/Roboto-Medium.ttf b/devexp_demos/devtools_companion/assets/fonts/Roboto/Roboto-Medium.ttf new file mode 100755 index 0000000..1a7f3b0 Binary files /dev/null and b/devexp_demos/devtools_companion/assets/fonts/Roboto/Roboto-Medium.ttf differ diff --git a/devexp_demos/devtools_companion/assets/fonts/Roboto/Roboto-Regular.ttf b/devexp_demos/devtools_companion/assets/fonts/Roboto/Roboto-Regular.ttf new file mode 100755 index 0000000..2c97eea Binary files /dev/null and b/devexp_demos/devtools_companion/assets/fonts/Roboto/Roboto-Regular.ttf differ diff --git a/devexp_demos/devtools_companion/assets/fonts/Roboto/Roboto-Thin.ttf b/devexp_demos/devtools_companion/assets/fonts/Roboto/Roboto-Thin.ttf new file mode 100755 index 0000000..b74a4fd Binary files /dev/null and b/devexp_demos/devtools_companion/assets/fonts/Roboto/Roboto-Thin.ttf differ diff --git a/devexp_demos/devtools_companion/assets/fonts/Roboto_Mono/LICENSE.txt b/devexp_demos/devtools_companion/assets/fonts/Roboto_Mono/LICENSE.txt new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/devexp_demos/devtools_companion/assets/fonts/Roboto_Mono/LICENSE.txt @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/devexp_demos/devtools_companion/assets/fonts/Roboto_Mono/RobotoMono-Bold.ttf b/devexp_demos/devtools_companion/assets/fonts/Roboto_Mono/RobotoMono-Bold.ttf new file mode 100644 index 0000000..482f028 Binary files /dev/null and b/devexp_demos/devtools_companion/assets/fonts/Roboto_Mono/RobotoMono-Bold.ttf differ diff --git a/devexp_demos/devtools_companion/assets/fonts/Roboto_Mono/RobotoMono-Light.ttf b/devexp_demos/devtools_companion/assets/fonts/Roboto_Mono/RobotoMono-Light.ttf new file mode 100644 index 0000000..3c845d4 Binary files /dev/null and b/devexp_demos/devtools_companion/assets/fonts/Roboto_Mono/RobotoMono-Light.ttf differ diff --git a/devexp_demos/devtools_companion/assets/fonts/Roboto_Mono/RobotoMono-Medium.ttf b/devexp_demos/devtools_companion/assets/fonts/Roboto_Mono/RobotoMono-Medium.ttf new file mode 100644 index 0000000..c496725 Binary files /dev/null and b/devexp_demos/devtools_companion/assets/fonts/Roboto_Mono/RobotoMono-Medium.ttf differ diff --git a/devexp_demos/devtools_companion/assets/fonts/Roboto_Mono/RobotoMono-Regular.ttf b/devexp_demos/devtools_companion/assets/fonts/Roboto_Mono/RobotoMono-Regular.ttf new file mode 100644 index 0000000..5919b5d Binary files /dev/null and b/devexp_demos/devtools_companion/assets/fonts/Roboto_Mono/RobotoMono-Regular.ttf differ diff --git a/devexp_demos/devtools_companion/assets/fonts/Roboto_Mono/RobotoMono-Thin.ttf b/devexp_demos/devtools_companion/assets/fonts/Roboto_Mono/RobotoMono-Thin.ttf new file mode 100644 index 0000000..65bf26a Binary files /dev/null and b/devexp_demos/devtools_companion/assets/fonts/Roboto_Mono/RobotoMono-Thin.ttf differ diff --git a/devexp_demos/devtools_companion/assets/icons/app_size.png b/devexp_demos/devtools_companion/assets/icons/app_size.png new file mode 100644 index 0000000..9019605 Binary files /dev/null and b/devexp_demos/devtools_companion/assets/icons/app_size.png differ diff --git a/devexp_demos/devtools_companion/assets/icons/cpu_profiler.png b/devexp_demos/devtools_companion/assets/icons/cpu_profiler.png new file mode 100644 index 0000000..4cbbbe4 Binary files /dev/null and b/devexp_demos/devtools_companion/assets/icons/cpu_profiler.png differ diff --git a/devexp_demos/devtools_companion/assets/icons/debugger.png b/devexp_demos/devtools_companion/assets/icons/debugger.png new file mode 100644 index 0000000..cc73dc1 Binary files /dev/null and b/devexp_demos/devtools_companion/assets/icons/debugger.png differ diff --git a/devexp_demos/devtools_companion/assets/icons/deep_links.png b/devexp_demos/devtools_companion/assets/icons/deep_links.png new file mode 100644 index 0000000..74eb1c9 Binary files /dev/null and b/devexp_demos/devtools_companion/assets/icons/deep_links.png differ diff --git a/devexp_demos/devtools_companion/assets/icons/devtools.png b/devexp_demos/devtools_companion/assets/icons/devtools.png new file mode 100644 index 0000000..a3de7b9 Binary files /dev/null and b/devexp_demos/devtools_companion/assets/icons/devtools.png differ diff --git a/devexp_demos/devtools_companion/assets/icons/inspector.png b/devexp_demos/devtools_companion/assets/icons/inspector.png new file mode 100644 index 0000000..c5dee4b Binary files /dev/null and b/devexp_demos/devtools_companion/assets/icons/inspector.png differ diff --git a/devexp_demos/devtools_companion/assets/icons/logging.png b/devexp_demos/devtools_companion/assets/icons/logging.png new file mode 100644 index 0000000..b64d206 Binary files /dev/null and b/devexp_demos/devtools_companion/assets/icons/logging.png differ diff --git a/devexp_demos/devtools_companion/assets/icons/memory.png b/devexp_demos/devtools_companion/assets/icons/memory.png new file mode 100644 index 0000000..91f7527 Binary files /dev/null and b/devexp_demos/devtools_companion/assets/icons/memory.png differ diff --git a/devexp_demos/devtools_companion/assets/icons/network.png b/devexp_demos/devtools_companion/assets/icons/network.png new file mode 100644 index 0000000..30ed61d Binary files /dev/null and b/devexp_demos/devtools_companion/assets/icons/network.png differ diff --git a/devexp_demos/devtools_companion/assets/icons/performance.png b/devexp_demos/devtools_companion/assets/icons/performance.png new file mode 100644 index 0000000..52697f3 Binary files /dev/null and b/devexp_demos/devtools_companion/assets/icons/performance.png differ diff --git a/devexp_demos/devtools_companion/assets/images/apple.jpg b/devexp_demos/devtools_companion/assets/images/apple.jpg new file mode 100644 index 0000000..7305880 Binary files /dev/null and b/devexp_demos/devtools_companion/assets/images/apple.jpg differ diff --git a/devexp_demos/devtools_companion/assets/images/artichoke.jpg b/devexp_demos/devtools_companion/assets/images/artichoke.jpg new file mode 100644 index 0000000..43337dd Binary files /dev/null and b/devexp_demos/devtools_companion/assets/images/artichoke.jpg differ diff --git a/devexp_demos/devtools_companion/assets/images/asparagus.jpg b/devexp_demos/devtools_companion/assets/images/asparagus.jpg new file mode 100644 index 0000000..76d340e Binary files /dev/null and b/devexp_demos/devtools_companion/assets/images/asparagus.jpg differ diff --git a/devexp_demos/devtools_companion/assets/images/avocado.jpg b/devexp_demos/devtools_companion/assets/images/avocado.jpg new file mode 100644 index 0000000..16ec1e7 Binary files /dev/null and b/devexp_demos/devtools_companion/assets/images/avocado.jpg differ diff --git a/devexp_demos/devtools_companion/assets/images/blackberry.jpg b/devexp_demos/devtools_companion/assets/images/blackberry.jpg new file mode 100644 index 0000000..4d8193a Binary files /dev/null and b/devexp_demos/devtools_companion/assets/images/blackberry.jpg differ diff --git a/devexp_demos/devtools_companion/assets/images/cantaloupe.jpg b/devexp_demos/devtools_companion/assets/images/cantaloupe.jpg new file mode 100644 index 0000000..f048570 Binary files /dev/null and b/devexp_demos/devtools_companion/assets/images/cantaloupe.jpg differ diff --git a/devexp_demos/devtools_companion/assets/images/cauliflower.jpg b/devexp_demos/devtools_companion/assets/images/cauliflower.jpg new file mode 100644 index 0000000..ce2aa91 Binary files /dev/null and b/devexp_demos/devtools_companion/assets/images/cauliflower.jpg differ diff --git a/devexp_demos/devtools_companion/assets/images/endive.jpg b/devexp_demos/devtools_companion/assets/images/endive.jpg new file mode 100644 index 0000000..80c5b7e Binary files /dev/null and b/devexp_demos/devtools_companion/assets/images/endive.jpg differ diff --git a/devexp_demos/devtools_companion/assets/images/fig.jpg b/devexp_demos/devtools_companion/assets/images/fig.jpg new file mode 100644 index 0000000..baaeafe Binary files /dev/null and b/devexp_demos/devtools_companion/assets/images/fig.jpg differ diff --git a/devexp_demos/devtools_companion/assets/images/grape.jpg b/devexp_demos/devtools_companion/assets/images/grape.jpg new file mode 100644 index 0000000..94cd645 Binary files /dev/null and b/devexp_demos/devtools_companion/assets/images/grape.jpg differ diff --git a/devexp_demos/devtools_companion/assets/images/green_bell_pepper.jpg b/devexp_demos/devtools_companion/assets/images/green_bell_pepper.jpg new file mode 100644 index 0000000..f9fac8c Binary files /dev/null and b/devexp_demos/devtools_companion/assets/images/green_bell_pepper.jpg differ diff --git a/devexp_demos/devtools_companion/assets/images/habanero.jpg b/devexp_demos/devtools_companion/assets/images/habanero.jpg new file mode 100644 index 0000000..3d85abd Binary files /dev/null and b/devexp_demos/devtools_companion/assets/images/habanero.jpg differ diff --git a/devexp_demos/devtools_companion/assets/images/kale.jpg b/devexp_demos/devtools_companion/assets/images/kale.jpg new file mode 100644 index 0000000..7a09929 Binary files /dev/null and b/devexp_demos/devtools_companion/assets/images/kale.jpg differ diff --git a/devexp_demos/devtools_companion/assets/images/kiwi.jpg b/devexp_demos/devtools_companion/assets/images/kiwi.jpg new file mode 100644 index 0000000..21be2a6 Binary files /dev/null and b/devexp_demos/devtools_companion/assets/images/kiwi.jpg differ diff --git a/devexp_demos/devtools_companion/assets/images/lemon.jpg b/devexp_demos/devtools_companion/assets/images/lemon.jpg new file mode 100644 index 0000000..62bd785 Binary files /dev/null and b/devexp_demos/devtools_companion/assets/images/lemon.jpg differ diff --git a/devexp_demos/devtools_companion/assets/images/lime.jpg b/devexp_demos/devtools_companion/assets/images/lime.jpg new file mode 100644 index 0000000..a9878a5 Binary files /dev/null and b/devexp_demos/devtools_companion/assets/images/lime.jpg differ diff --git a/devexp_demos/devtools_companion/assets/images/mango.jpg b/devexp_demos/devtools_companion/assets/images/mango.jpg new file mode 100644 index 0000000..27a82e5 Binary files /dev/null and b/devexp_demos/devtools_companion/assets/images/mango.jpg differ diff --git a/devexp_demos/devtools_companion/assets/images/mushroom.jpg b/devexp_demos/devtools_companion/assets/images/mushroom.jpg new file mode 100644 index 0000000..a12fc69 Binary files /dev/null and b/devexp_demos/devtools_companion/assets/images/mushroom.jpg differ diff --git a/devexp_demos/devtools_companion/assets/images/nectarine.jpg b/devexp_demos/devtools_companion/assets/images/nectarine.jpg new file mode 100644 index 0000000..24b55b7 Binary files /dev/null and b/devexp_demos/devtools_companion/assets/images/nectarine.jpg differ diff --git a/devexp_demos/devtools_companion/assets/images/orange_bell_pepper.jpg b/devexp_demos/devtools_companion/assets/images/orange_bell_pepper.jpg new file mode 100644 index 0000000..2f78910 Binary files /dev/null and b/devexp_demos/devtools_companion/assets/images/orange_bell_pepper.jpg differ diff --git a/devexp_demos/devtools_companion/assets/images/persimmon.jpg b/devexp_demos/devtools_companion/assets/images/persimmon.jpg new file mode 100644 index 0000000..c0e5d07 Binary files /dev/null and b/devexp_demos/devtools_companion/assets/images/persimmon.jpg differ diff --git a/devexp_demos/devtools_companion/assets/images/plum.jpg b/devexp_demos/devtools_companion/assets/images/plum.jpg new file mode 100644 index 0000000..743c7a4 Binary files /dev/null and b/devexp_demos/devtools_companion/assets/images/plum.jpg differ diff --git a/devexp_demos/devtools_companion/assets/images/potato.jpg b/devexp_demos/devtools_companion/assets/images/potato.jpg new file mode 100644 index 0000000..0f09e21 Binary files /dev/null and b/devexp_demos/devtools_companion/assets/images/potato.jpg differ diff --git a/devexp_demos/devtools_companion/assets/images/radicchio.jpg b/devexp_demos/devtools_companion/assets/images/radicchio.jpg new file mode 100644 index 0000000..acd8f66 Binary files /dev/null and b/devexp_demos/devtools_companion/assets/images/radicchio.jpg differ diff --git a/devexp_demos/devtools_companion/assets/images/radish.jpg b/devexp_demos/devtools_companion/assets/images/radish.jpg new file mode 100644 index 0000000..0dcbfb7 Binary files /dev/null and b/devexp_demos/devtools_companion/assets/images/radish.jpg differ diff --git a/devexp_demos/devtools_companion/assets/images/squash.jpg b/devexp_demos/devtools_companion/assets/images/squash.jpg new file mode 100644 index 0000000..d20b745 Binary files /dev/null and b/devexp_demos/devtools_companion/assets/images/squash.jpg differ diff --git a/devexp_demos/devtools_companion/assets/images/strawberry.jpg b/devexp_demos/devtools_companion/assets/images/strawberry.jpg new file mode 100644 index 0000000..feecd8a Binary files /dev/null and b/devexp_demos/devtools_companion/assets/images/strawberry.jpg differ diff --git a/devexp_demos/devtools_companion/assets/images/tangelo.jpg b/devexp_demos/devtools_companion/assets/images/tangelo.jpg new file mode 100644 index 0000000..83c0a03 Binary files /dev/null and b/devexp_demos/devtools_companion/assets/images/tangelo.jpg differ diff --git a/devexp_demos/devtools_companion/assets/images/tomato.jpg b/devexp_demos/devtools_companion/assets/images/tomato.jpg new file mode 100644 index 0000000..ab36947 Binary files /dev/null and b/devexp_demos/devtools_companion/assets/images/tomato.jpg differ diff --git a/devexp_demos/devtools_companion/assets/images/watermelon.jpg b/devexp_demos/devtools_companion/assets/images/watermelon.jpg new file mode 100644 index 0000000..43880ba Binary files /dev/null and b/devexp_demos/devtools_companion/assets/images/watermelon.jpg differ diff --git a/devexp_demos/devtools_companion/devtools_options.yaml b/devexp_demos/devtools_companion/devtools_options.yaml new file mode 100644 index 0000000..fa0b357 --- /dev/null +++ b/devexp_demos/devtools_companion/devtools_options.yaml @@ -0,0 +1,3 @@ +description: This file stores settings for Dart & Flutter DevTools. +documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states +extensions: diff --git a/devexp_demos/devtools_companion/ios/.gitignore b/devexp_demos/devtools_companion/ios/.gitignore new file mode 100644 index 0000000..7a7f987 --- /dev/null +++ b/devexp_demos/devtools_companion/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/devexp_demos/devtools_companion/ios/Flutter/AppFrameworkInfo.plist b/devexp_demos/devtools_companion/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 0000000..1dc6cf7 --- /dev/null +++ b/devexp_demos/devtools_companion/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/devexp_demos/devtools_companion/ios/Flutter/Debug.xcconfig b/devexp_demos/devtools_companion/ios/Flutter/Debug.xcconfig new file mode 100644 index 0000000..ec97fc6 --- /dev/null +++ b/devexp_demos/devtools_companion/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/devexp_demos/devtools_companion/ios/Flutter/Release.xcconfig b/devexp_demos/devtools_companion/ios/Flutter/Release.xcconfig new file mode 100644 index 0000000..c4855bf --- /dev/null +++ b/devexp_demos/devtools_companion/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/devexp_demos/devtools_companion/ios/Podfile b/devexp_demos/devtools_companion/ios/Podfile new file mode 100644 index 0000000..620e46e --- /dev/null +++ b/devexp_demos/devtools_companion/ios/Podfile @@ -0,0 +1,43 @@ +# 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! + + 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/devexp_demos/devtools_companion/ios/Runner.xcodeproj/project.pbxproj b/devexp_demos/devtools_companion/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..bad0d5c --- /dev/null +++ b/devexp_demos/devtools_companion/ios/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,731 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXBuildFile section */ + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 859F50BB9A9682AFFED2D320 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 464BA753E18C80D3117574CA /* Pods_Runner.framework */; }; + 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 */; }; + CDD605E37C66C638E52A0F7A /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 85A313AD8D9D180B0CD74893 /* Pods_RunnerTests.framework */; }; +/* 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 */ + 054FB362BE923AB8B52F8282 /* 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 = ""; }; + 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 = ""; }; + 1563BC4FE7F3BFCB53A9F88F /* 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 = ""; }; + 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 = ""; }; + 464BA753E18C80D3117574CA /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 5A1E1981940D8AB38EC2EAC3 /* 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 = ""; }; + 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 = ""; }; + 85A313AD8D9D180B0CD74893 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 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 = ""; }; + 98D87F540115B0D07691EA6C /* 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 = ""; }; + BC1EF832CA704198527FAC2D /* 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 = ""; }; + CB24B5AA7F9232A8C43035E9 /* 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 = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 3F4980AEFBFEDC9A2960D47E /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + CDD605E37C66C638E52A0F7A /* Pods_RunnerTests.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EB1CF9000F007C117D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 859F50BB9A9682AFFED2D320 /* Pods_Runner.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C8082294A63A400263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C807B294A618700263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 388791C178C01188BCC0FBBF /* Pods */ = { + isa = PBXGroup; + children = ( + 054FB362BE923AB8B52F8282 /* Pods-Runner.debug.xcconfig */, + BC1EF832CA704198527FAC2D /* Pods-Runner.release.xcconfig */, + CB24B5AA7F9232A8C43035E9 /* Pods-Runner.profile.xcconfig */, + 1563BC4FE7F3BFCB53A9F88F /* Pods-RunnerTests.debug.xcconfig */, + 98D87F540115B0D07691EA6C /* Pods-RunnerTests.release.xcconfig */, + 5A1E1981940D8AB38EC2EAC3 /* Pods-RunnerTests.profile.xcconfig */, + ); + name = Pods; + path = Pods; + 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 */, + 388791C178C01188BCC0FBBF /* Pods */, + B4DEA10401457CEC0FEB0DA3 /* 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 = ""; + }; + B4DEA10401457CEC0FEB0DA3 /* Frameworks */ = { + isa = PBXGroup; + children = ( + 464BA753E18C80D3117574CA /* Pods_Runner.framework */, + 85A313AD8D9D180B0CD74893 /* 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 = ( + 37DFBE7D49B083362F8F1257 /* [CP] Check Pods Manifest.lock */, + 331C807D294A63A400263BE5 /* Sources */, + 331C807F294A63A400263BE5 /* Resources */, + 3F4980AEFBFEDC9A2960D47E /* 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 = ( + 6826B31A2D969D035448735C /* [CP] Check Pods Manifest.lock */, + 9740EEB61CF901F6004384FC /* Run Script */, + 97C146EA1CF9000F007C117D /* Sources */, + 97C146EB1CF9000F007C117D /* Frameworks */, + 97C146EC1CF9000F007C117D /* Resources */, + 9705A1C41CF9048500538489 /* Embed Frameworks */, + 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + AB9F7A029FFC3E0780CF0AEE /* [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 */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 37DFBE7D49B083362F8F1257 /* [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; + }; + 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"; + }; + 6826B31A2D969D035448735C /* [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"; + }; + AB9F7A029FFC3E0780CF0AEE /* [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 = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Profile; + }; + 249021D4217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = F6TVK29MCA; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.devtoolsCompanion; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Profile; + }; + 331C8088294A63A400263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 1563BC4FE7F3BFCB53A9F88F /* 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.devtoolsCompanion.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 = 98D87F540115B0D07691EA6C /* 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.devtoolsCompanion.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 = 5A1E1981940D8AB38EC2EAC3 /* 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.devtoolsCompanion.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 = 13.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 97C147041CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 97C147061CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = F6TVK29MCA; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.devtoolsCompanion; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + 97C147071CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = F6TVK29MCA; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.devtoolsCompanion; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C8088294A63A400263BE5 /* Debug */, + 331C8089294A63A400263BE5 /* Release */, + 331C808A294A63A400263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147031CF9000F007C117D /* Debug */, + 97C147041CF9000F007C117D /* Release */, + 249021D3217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147061CF9000F007C117D /* Debug */, + 97C147071CF9000F007C117D /* Release */, + 249021D4217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 97C146E61CF9000F007C117D /* Project object */; +} diff --git a/devexp_demos/devtools_companion/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/devexp_demos/devtools_companion/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/devexp_demos/devtools_companion/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/devexp_demos/devtools_companion/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/devexp_demos/devtools_companion/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/devexp_demos/devtools_companion/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/devexp_demos/devtools_companion/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/devexp_demos/devtools_companion/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/devexp_demos/devtools_companion/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/devexp_demos/devtools_companion/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/devexp_demos/devtools_companion/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..e3773d4 --- /dev/null +++ b/devexp_demos/devtools_companion/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/devexp_demos/devtools_companion/ios/Runner.xcworkspace/contents.xcworkspacedata b/devexp_demos/devtools_companion/ios/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..21a3cc1 --- /dev/null +++ b/devexp_demos/devtools_companion/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/devexp_demos/devtools_companion/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/devexp_demos/devtools_companion/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/devexp_demos/devtools_companion/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/devexp_demos/devtools_companion/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/devexp_demos/devtools_companion/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/devexp_demos/devtools_companion/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/devexp_demos/devtools_companion/ios/Runner/AppDelegate.swift b/devexp_demos/devtools_companion/ios/Runner/AppDelegate.swift new file mode 100644 index 0000000..6266644 --- /dev/null +++ b/devexp_demos/devtools_companion/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/devexp_demos/devtools_companion/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/devexp_demos/devtools_companion/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..d36b1fa --- /dev/null +++ b/devexp_demos/devtools_companion/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/devexp_demos/devtools_companion/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/devexp_demos/devtools_companion/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 0000000..dc9ada4 Binary files /dev/null and b/devexp_demos/devtools_companion/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/devexp_demos/devtools_companion/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/devexp_demos/devtools_companion/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png new file mode 100644 index 0000000..7353c41 Binary files /dev/null and b/devexp_demos/devtools_companion/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/devexp_demos/devtools_companion/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/devexp_demos/devtools_companion/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 0000000..797d452 Binary files /dev/null and b/devexp_demos/devtools_companion/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/devexp_demos/devtools_companion/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/devexp_demos/devtools_companion/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 0000000..6ed2d93 Binary files /dev/null and b/devexp_demos/devtools_companion/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/devexp_demos/devtools_companion/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/devexp_demos/devtools_companion/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png new file mode 100644 index 0000000..4cd7b00 Binary files /dev/null and b/devexp_demos/devtools_companion/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/devexp_demos/devtools_companion/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/devexp_demos/devtools_companion/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 0000000..fe73094 Binary files /dev/null and b/devexp_demos/devtools_companion/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/devexp_demos/devtools_companion/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/devexp_demos/devtools_companion/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png new file mode 100644 index 0000000..321773c Binary files /dev/null and b/devexp_demos/devtools_companion/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/devexp_demos/devtools_companion/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/devexp_demos/devtools_companion/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png new file mode 100644 index 0000000..797d452 Binary files /dev/null and b/devexp_demos/devtools_companion/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/devexp_demos/devtools_companion/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/devexp_demos/devtools_companion/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 0000000..502f463 Binary files /dev/null and b/devexp_demos/devtools_companion/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/devexp_demos/devtools_companion/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/devexp_demos/devtools_companion/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png new file mode 100644 index 0000000..0ec3034 Binary files /dev/null and b/devexp_demos/devtools_companion/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/devexp_demos/devtools_companion/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/devexp_demos/devtools_companion/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 0000000..0ec3034 Binary files /dev/null and b/devexp_demos/devtools_companion/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/devexp_demos/devtools_companion/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/devexp_demos/devtools_companion/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 0000000..e9f5fea Binary files /dev/null and b/devexp_demos/devtools_companion/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/devexp_demos/devtools_companion/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/devexp_demos/devtools_companion/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png new file mode 100644 index 0000000..84ac32a Binary files /dev/null and b/devexp_demos/devtools_companion/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/devexp_demos/devtools_companion/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/devexp_demos/devtools_companion/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png new file mode 100644 index 0000000..8953cba Binary files /dev/null and b/devexp_demos/devtools_companion/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/devexp_demos/devtools_companion/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/devexp_demos/devtools_companion/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/devexp_demos/devtools_companion/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/devexp_demos/devtools_companion/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/devexp_demos/devtools_companion/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json new file mode 100644 index 0000000..0bedcf2 --- /dev/null +++ b/devexp_demos/devtools_companion/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/devexp_demos/devtools_companion/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/devexp_demos/devtools_companion/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/devexp_demos/devtools_companion/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ diff --git a/devexp_demos/devtools_companion/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/devexp_demos/devtools_companion/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/devexp_demos/devtools_companion/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ diff --git a/devexp_demos/devtools_companion/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/devexp_demos/devtools_companion/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/devexp_demos/devtools_companion/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ diff --git a/devexp_demos/devtools_companion/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/devexp_demos/devtools_companion/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md new file mode 100644 index 0000000..89c2725 --- /dev/null +++ b/devexp_demos/devtools_companion/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/devexp_demos/devtools_companion/ios/Runner/Base.lproj/LaunchScreen.storyboard b/devexp_demos/devtools_companion/ios/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..f2e259c --- /dev/null +++ b/devexp_demos/devtools_companion/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/devexp_demos/devtools_companion/ios/Runner/Base.lproj/Main.storyboard b/devexp_demos/devtools_companion/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 0000000..f3c2851 --- /dev/null +++ b/devexp_demos/devtools_companion/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/devexp_demos/devtools_companion/ios/Runner/Info.plist b/devexp_demos/devtools_companion/ios/Runner/Info.plist new file mode 100644 index 0000000..4a025db --- /dev/null +++ b/devexp_demos/devtools_companion/ios/Runner/Info.plist @@ -0,0 +1,49 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Devtools Companion + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + devtools_companion + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + CADisableMinimumFrameDurationOnPhone + + UIApplicationSupportsIndirectInputEvents + + + diff --git a/devexp_demos/devtools_companion/ios/Runner/Runner-Bridging-Header.h b/devexp_demos/devtools_companion/ios/Runner/Runner-Bridging-Header.h new file mode 100644 index 0000000..308a2a5 --- /dev/null +++ b/devexp_demos/devtools_companion/ios/Runner/Runner-Bridging-Header.h @@ -0,0 +1 @@ +#import "GeneratedPluginRegistrant.h" diff --git a/devexp_demos/devtools_companion/ios/RunnerTests/RunnerTests.swift b/devexp_demos/devtools_companion/ios/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..86a7c3b --- /dev/null +++ b/devexp_demos/devtools_companion/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/devexp_demos/devtools_companion/lib/driver_main.dart b/devexp_demos/devtools_companion/lib/driver_main.dart new file mode 100644 index 0000000..14122aa --- /dev/null +++ b/devexp_demos/devtools_companion/lib/driver_main.dart @@ -0,0 +1,10 @@ +import 'package:flutter_driver/driver_extension.dart'; + +import 'main.dart' as app; + +/// This entry point is used for AI agents to run the app with the flutter +/// driver extension enabled. +void main() { + enableFlutterDriverExtension(); + app.main(); +} diff --git a/devexp_demos/devtools_companion/lib/main.dart b/devexp_demos/devtools_companion/lib/main.dart new file mode 100644 index 0000000..962dd4b --- /dev/null +++ b/devexp_demos/devtools_companion/lib/main.dart @@ -0,0 +1,71 @@ +import 'dart:developer' as developer; + +import 'package:flutter/material.dart'; +import 'package:logging/logging.dart'; +import 'package:provider/provider.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +import 'src/scaffold/router.dart'; +import 'src/scaffold/theme_notifier.dart'; + +void main() { + Logger.root.level = Level.ALL; + Logger.root.onRecord.listen((record) { + developer.log( + record.message, + time: record.time, + sequenceNumber: record.sequenceNumber, + level: record.level.value, + name: record.loggerName, + zone: record.zone, + error: record.error, + stackTrace: record.stackTrace, + ); + }); + + runApp( + ChangeNotifierProvider( + create: (_) => ThemeNotifier(), + child: const DevToolsCompanionApp(), + ), + ); +} + +class DevToolsCompanionApp extends StatefulWidget { + const DevToolsCompanionApp({super.key}); + + @override + State createState() => _DevToolsCompanionAppState(); +} + +class _DevToolsCompanionAppState extends State { + static const _primaryLight = Color.fromARGB(255, 25, 91, 184); + static const _primaryDark = Color.fromARGB(255, 47, 131, 249); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Consumer( + builder: (context, themeNotifier, child) => ShadApp( + title: 'DevTools Companion App', + theme: ShadThemeData( + brightness: Brightness.light, + colorScheme: const ShadSlateColorScheme.light( + primary: _primaryLight, + ), + ), + darkTheme: ShadThemeData( + brightness: Brightness.dark, + colorScheme: const ShadSlateColorScheme.dark(primary: _primaryDark), + ), + themeMode: themeNotifier.themeMode, + initialRoute: home, + onGenerateRoute: onGenerateRoute, + builder: (context, child) { + return ShadToaster(child: child!); + }, + ), + ), + ); + } +} diff --git a/devexp_demos/devtools_companion/lib/src/scaffold/app_drawer.dart b/devexp_demos/devtools_companion/lib/src/scaffold/app_drawer.dart new file mode 100644 index 0000000..3fdcf34 --- /dev/null +++ b/devexp_demos/devtools_companion/lib/src/scaffold/app_drawer.dart @@ -0,0 +1,81 @@ +import 'package:flutter/material.dart'; + +import 'router.dart'; + +class AppDrawer extends StatelessWidget { + const AppDrawer({super.key}); + + static const _drawerWidth = 200.0; + static const _topSpacing = 60.0; + + @override + Widget build(BuildContext context) { + final currentPath = + ModalRoute.of(context)?.settings.name ?? AppRoute.home.path; + return Drawer( + width: _drawerWidth, + child: ListView( + padding: const EdgeInsets.only(top: _topSpacing), + children: AppRoute.values + .where((route) => route.showInAppDrawer) + .map((route) => _DrawerItem(route: route, currentPath: currentPath)) + .toList(), + ), + ); + } +} + +class _DrawerItem extends StatelessWidget { + const _DrawerItem({required this.route, required this.currentPath}); + + final AppRoute route; + final String currentPath; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isSelected = currentPath == route.path; + final color = isSelected + ? theme.colorScheme.primary + : theme.textTheme.bodyLarge?.color; + + return ListTile( + leading: AssetImageIcon(asset: route.iconAsset, color: color), + title: Text( + route.name, + style: TextStyle( + fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, + color: color, + ), + ), + selected: isSelected, + onTap: () async { + Navigator.pop(context); + if (!isSelected) { + await Navigator.pushReplacementNamed(context, route.path); + } + }, + ); + } +} + +/// A widget that renders an [asset] image styled as an icon. +final class AssetImageIcon extends StatelessWidget { + const AssetImageIcon({super.key, required this.asset, this.color}); + + static const _iconSize = 18.0; + + final String asset; + final Color? color; + + @override + Widget build(BuildContext context) { + return Image( + image: AssetImage(asset), + height: _iconSize, + width: _iconSize, + fit: BoxFit.fill, + color: color ?? Theme.of(context).colorScheme.onSurface, + ); + } +} diff --git a/devexp_demos/devtools_companion/lib/src/scaffold/app_shell.dart b/devexp_demos/devtools_companion/lib/src/scaffold/app_shell.dart new file mode 100644 index 0000000..79d061a --- /dev/null +++ b/devexp_demos/devtools_companion/lib/src/scaffold/app_shell.dart @@ -0,0 +1,41 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import './app_drawer.dart'; +import './theme_notifier.dart'; + +class AppShell extends StatelessWidget { + const AppShell({ + super.key, + required this.screenName, + required this.screenBody, + this.showDrawer = true, + }); + + final String screenName; + final Widget screenBody; + final bool showDrawer; + + @override + Widget build(BuildContext context) { + final themeNotifier = Provider.of(context); + final isDarkMode = themeNotifier.themeMode == ThemeMode.dark; + + return Scaffold( + appBar: AppBar( + title: Text(screenName), + actions: [ + IconButton( + icon: Icon(isDarkMode ? Icons.light_mode : Icons.dark_mode), + onPressed: () { + themeNotifier.toggleTheme(); + }, + tooltip: isDarkMode ? 'Light mode' : 'Dark mode', + ), + ], + ), + drawer: showDrawer ? const AppDrawer() : null, + body: Center(child: screenBody), + ); + } +} diff --git a/devexp_demos/devtools_companion/lib/src/scaffold/router.dart b/devexp_demos/devtools_companion/lib/src/scaffold/router.dart new file mode 100644 index 0000000..bf20ea5 --- /dev/null +++ b/devexp_demos/devtools_companion/lib/src/scaffold/router.dart @@ -0,0 +1,142 @@ +import 'package:flutter/material.dart'; + +import '../screens/cpu_profiler/cpu_profiler_screen.dart'; +import '../screens/debugger/debugger_screen.dart'; +import '../screens/home/home_screen.dart'; +import '../screens/inspector/inspector_screen.dart'; +import '../screens/logging/logging_screen.dart'; +import '../screens/memory/memory_screen.dart'; +import '../screens/network/network_screen.dart'; +import '../screens/performance/data/fruits.dart'; +import '../screens/performance/fruit_details.dart'; +import '../screens/performance/performance_screen.dart'; +import '../screens/timeline/timeline_screen.dart'; +import './app_shell.dart'; + +enum AppRoute { + home(name: 'Home', path: '/', iconAsset: 'assets/icons/devtools.png'), + inspector( + name: 'Inspector', + path: '/inspector', + iconAsset: 'assets/icons/inspector.png', + ), + performance( + name: 'Performance', + path: '/performance', + iconAsset: 'assets/icons/performance.png', + ), + performanceDetails( + name: 'Performance Details', + path: '/performance/details', + iconAsset: 'assets/icons/performance.png', + showInAppDrawer: false, + ), + timeline( + name: 'Timeline', + path: '/timeline', + // TODO: Choose a different icon. + iconAsset: 'assets/icons/debugger.png', + ), + network( + name: 'Network', + path: '/network', + iconAsset: 'assets/icons/network.png', + ), + memory(name: 'Memory', path: '/memory', iconAsset: 'assets/icons/memory.png'), + cpuProfiler( + name: 'CPU Profiler', + path: '/cpu-profiler', + iconAsset: 'assets/icons/cpu_profiler.png', + ), + debugger( + name: 'Debugger', + path: '/debugger', + iconAsset: 'assets/icons/debugger.png', + ), + logging( + name: 'Logging', + path: '/logging', + iconAsset: 'assets/icons/logging.png', + ); + + const AppRoute({ + required this.name, + required this.path, + required this.iconAsset, + this.showInAppDrawer = true, + }); + final String path; + final String name; + final String iconAsset; + final bool showInAppDrawer; + + static AppRoute fromPath(String? path) { + if (path == null) return AppRoute.home; + return AppRoute.values.firstWhere( + (e) => e.path == path, + orElse: () => AppRoute.home, + ); + } +} + +String get home => AppRoute.home.path; + +Route onGenerateRoute(RouteSettings settings) { + final page = AppRoute.fromPath(settings.name); + return MaterialPageRoute( + settings: settings, + builder: (context) { + switch (page) { + case AppRoute.inspector: + return const AppShell( + screenName: 'Inspector', + screenBody: InspectorScreen(), + ); + case AppRoute.performance: + return const AppShell( + screenName: 'Performance', + screenBody: PerformanceScreen(), + ); + case AppRoute.performanceDetails: + final fruit = settings.arguments as Fruit; + return AppShell( + screenName: 'Performance', + screenBody: FruitDetailsScreen(fruit: fruit), + showDrawer: false, + ); + case AppRoute.timeline: + return const AppShell( + screenName: 'Timeline', + screenBody: TimelineScreen(), + ); + case AppRoute.network: + return const AppShell( + screenName: 'Network', + screenBody: NetworkScreen(), + ); + case AppRoute.memory: + return const AppShell( + screenName: 'Memory', + screenBody: MemoryScreen(), + ); + case AppRoute.cpuProfiler: + return const AppShell( + screenName: 'CPU Profiler', + screenBody: CpuProfilerScreen(), + ); + case AppRoute.debugger: + return const AppShell( + screenName: 'Debugger', + screenBody: DebuggerScreen(), + ); + case AppRoute.logging: + return const AppShell( + screenName: 'Logging', + screenBody: LoggingScreen(), + ); + case AppRoute.home: + return const AppShell(screenName: 'Home', screenBody: HomeScreen()); + } + }, + ); +} diff --git a/devexp_demos/devtools_companion/lib/src/scaffold/theme_notifier.dart b/devexp_demos/devtools_companion/lib/src/scaffold/theme_notifier.dart new file mode 100644 index 0000000..9e19ef3 --- /dev/null +++ b/devexp_demos/devtools_companion/lib/src/scaffold/theme_notifier.dart @@ -0,0 +1,13 @@ +import 'package:flutter/material.dart'; + +class ThemeNotifier extends ChangeNotifier { + ThemeMode _themeMode = ThemeMode.light; + + ThemeMode get themeMode => _themeMode; + + void toggleTheme() { + _themeMode = + _themeMode == ThemeMode.light ? ThemeMode.dark : ThemeMode.light; + notifyListeners(); + } +} diff --git a/devexp_demos/devtools_companion/lib/src/screens/cpu_profiler/cpu_profiler_screen.dart b/devexp_demos/devtools_companion/lib/src/screens/cpu_profiler/cpu_profiler_screen.dart new file mode 100644 index 0000000..bca150c --- /dev/null +++ b/devexp_demos/devtools_companion/lib/src/screens/cpu_profiler/cpu_profiler_screen.dart @@ -0,0 +1,69 @@ +import 'dart:async'; +import 'dart:isolate'; + +import 'package:flutter/material.dart'; + +import '../../shared/ui/theme.dart'; +import '../../shared/widgets/expensive_task_widget.dart'; +import 'party_poppers.dart'; + +class CpuProfilerScreen extends StatefulWidget { + const CpuProfilerScreen({super.key}); + + @override + State createState() => _CpuProfilerScreenState(); +} + +class _CpuProfilerScreenState extends State { + final _partyPopperController = PartyPopperController(); + + @override + Widget build(BuildContext context) { + return ListView( + padding: const .all(largePadding), + children: [ + const ExpensiveTaskWidget( + title: 'Compute Fibonacci (37)', + task: runFib37, + ), + ExpensiveTaskWidget( + title: 'Compute Fibonacci (37) in Isolate', + task: () => Isolate.run(runFib37), + ), + ExpensiveTaskWidget( + title: 'Compute FibonacciAsync (20) x5', + task: () async { + final results = await Future.wait([ + for (var i = 0; i < 5; i++) fibAsync20(), + ]); + return '${results.reduce((a, b) => a + b)}'; + }, + ), + ExpensiveTaskWidget( + title: 'Create 100 Party Poppers', + children: [PartyPopperManager(controller: _partyPopperController)], + task: () async { + await _partyPopperController.start(100); + return 'Hooray!'; + }, + ), + ], + ); + } +} + +Future fibAsync20() => _fibAsync(20); + +Future _fibAsync(int n) async { + if (n <= 1) return n; + await Future.delayed(const Duration(milliseconds: 16)); + final (a, b) = await (_fibAsync(n - 1), _fibAsync(n - 2)).wait; + return a + b; +} + +String runFib37() => _fib(37).toString(); + +int _fib(int n) { + if (n <= 1) return n; + return _fib(n - 1) + _fib(n - 2); +} diff --git a/devexp_demos/devtools_companion/lib/src/screens/cpu_profiler/party_poppers.dart b/devexp_demos/devtools_companion/lib/src/screens/cpu_profiler/party_poppers.dart new file mode 100644 index 0000000..4f97d6b --- /dev/null +++ b/devexp_demos/devtools_companion/lib/src/screens/cpu_profiler/party_poppers.dart @@ -0,0 +1,210 @@ +import 'dart:async'; +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:flutter_animate/flutter_animate.dart'; + +class PartyPopperController { + _PartyPopperManagerState? _state; + + void _attach(_PartyPopperManagerState state) { + _state = state; + } + + void _detach() { + _state = null; + } + + Future start(int count) async { + if (_state != null) { + await _state!.addPoppers(count); + } + } +} + +class PartyPopperManager extends StatefulWidget { + const PartyPopperManager({super.key, required this.controller}); + + final PartyPopperController controller; + + @override + State createState() => _PartyPopperManagerState(); +} + +class _PartyPopperManagerState extends State { + int _count = 0; + int _runId = 0; + List> _completers = []; + + @override + void initState() { + super.initState(); + widget.controller._attach(this); + } + + @override + void didUpdateWidget(PartyPopperManager oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + oldWidget.controller._detach(); + widget.controller._attach(this); + } + } + + @override + void dispose() { + widget.controller._detach(); + for (final c in _completers) { + if (!c.isCompleted) c.complete(); + } + _completers = []; + super.dispose(); + } + + Future addPoppers(int count) async { + if (!mounted) return; + if (_completers.isNotEmpty) return; + + // Complete any previous run to avoid hanging + for (final c in _completers) { + if (!c.isCompleted) c.complete(); + } + + setState(() { + _count = count; + _runId++; + _completers = List.generate(count, (_) => Completer()); + }); + + // Wait for all animations to complete + await Future.wait(_completers.map((c) => c.future)); + if (!mounted) return; + setState(() { + _completers = []; + }); + } + + @override + Widget build(BuildContext context) { + if (_completers.isEmpty) return const SizedBox.shrink(); + return Expanded( + child: SizedBox( + height: 300, + child: LayoutBuilder( + builder: (context, constraints) { + return Stack( + children: List.generate(_count, (index) { + final random = Random(index); + return Positioned( + left: random.nextDouble() * constraints.maxWidth, + top: random.nextDouble() * constraints.maxHeight, + child: PartyPopper( + key: ValueKey('$_runId-$index'), + seed: index, + delay: Duration(milliseconds: index * 50), + onDone: () { + if (index < _completers.length && + !_completers[index].isCompleted) { + _completers[index].complete(); + } + }, + ), + ); + }), + ); + }, + ), + ), + ); + } +} + +class PartyPopper extends StatefulWidget { + const PartyPopper({ + super.key, + this.seed = 0, + this.delay = Duration.zero, + this.onDone, + }); + + final int seed; + final Duration delay; + final VoidCallback? onDone; + + @override + State createState() => _PartyPopperState(); +} + +class _PartyPopperState extends State { + bool _visible = false; + + @override + void initState() { + super.initState(); + if (widget.delay == Duration.zero) { + _visible = true; + } else { + Future.delayed(widget.delay, () { + if (mounted) { + setState(() { + _visible = true; + }); + } + }); + } + } + + @override + Widget build(BuildContext context) { + if (!_visible) return const SizedBox.shrink(); + + return IgnorePointer( + child: Stack( + alignment: Alignment.center, + children: [ + // Confetti + ...List.generate(50, (index) { + final random = Random(widget.seed * 1000 + index); + final angle = pi + random.nextDouble() * pi; + final distance = 100 + random.nextDouble() * 200; + final color = + Colors.primaries[random.nextInt(Colors.primaries.length)]; + + return Positioned( + child: + Container( + width: 2, + height: 2, + decoration: BoxDecoration( + color: color, + shape: BoxShape.circle, + ), + ) + .animate() + .move( + delay: 0.ms, + duration: 1500.ms, + begin: Offset.zero, + end: Offset( + cos(angle) * distance, + sin(angle) * distance, + ), + curve: Curves.easeOut, + ) + .fadeOut(delay: 500.ms, duration: 500.ms), + ); + }), + const Text('🎉', style: TextStyle(fontSize: 16)) + .animate(onComplete: (controller) => widget.onDone?.call()) + .scale(duration: 500.ms, curve: Curves.elasticOut) + .shake(delay: 500.ms, duration: 500.ms) + .fadeOut( + delay: 1000.ms, + duration: 1000.ms, + curve: Curves.elasticOut, + ), + ], + ), + ); + } +} diff --git a/devexp_demos/devtools_companion/lib/src/screens/debugger/debugger_screen.dart b/devexp_demos/devtools_companion/lib/src/screens/debugger/debugger_screen.dart new file mode 100644 index 0000000..69c487d --- /dev/null +++ b/devexp_demos/devtools_companion/lib/src/screens/debugger/debugger_screen.dart @@ -0,0 +1,195 @@ +import 'dart:async'; +import 'dart:collection'; +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +import '../../shared/ui/theme.dart'; + +class DebuggerScreen extends StatefulWidget { + const DebuggerScreen({super.key}); + + @override + State createState() => _DebuggerScreenState(); +} + +class _DebuggerScreenState extends State { + // --- Inspectable Data Structures --- + final List _people = []; + final Map _complexMap = { + 'config': { + 'version': 1.0, + 'features': ['fast', 'secure', 'modern'], + }, + 'metadata': {'cratedAt': DateTime.now().toIso8601String()}, + }; + final Set _uniqueIds = {}; + final LinkedList _linkedList = LinkedList(); + + Node? _binaryTreeRoot; + + Timer? _tickerTimer; + int _tickerSeconds = 0; + + int _counter = 0; + + @override + void initState() { + super.initState(); + _initializeData(); + _startTicker(); + } + + @override + void dispose() { + _tickerTimer?.cancel(); + super.dispose(); + } + + void _startTicker() { + _tickerTimer = Timer.periodic(const Duration(seconds: 1), _onTick); + } + + void _onTick(Timer timer) { + setState(() { + _tickerSeconds++; + }); + // PLACE BREAKPOINT HERE to evaluate the code in the DevTools debugger + // + debugPrint('Ticker tick: $_tickerSeconds'); + } + + void _initializeData() { + // Populate heavy data for inspection + for (int i = 0; i < 5; i++) { + _people.add( + Person( + name: 'User $i', + age: 20 + i, + attributes: {'role': i % 2 == 0 ? 'Admin' : 'User'}, + ), + ); + } + + _uniqueIds.addAll(['ID-A', 'ID-B', 'ID-C']); + + _linkedList.addAll([CustomEntry(100), CustomEntry(200), CustomEntry(300)]); + + _binaryTreeRoot = Node( + id: 1, + left: Node(id: 2, left: Node(id: 4), right: Node(id: 5)), + right: Node(id: 3, right: Node(id: 6)), + ); + } + + void _addPerson() { + setState(() { + _people.add(Person(name: 'New User $_counter', age: 10 + _counter)); + _counter++; + }); + } + + void _modifyMap() { + setState(() { + _complexMap['update_$_counter'] = Random().nextInt(1000); + (_complexMap['config'] as Map)['last_updated'] = DateTime.now(); + _counter++; + }); + } + + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + padding: const EdgeInsets.all(largeSpacing), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Debugger Object Zoo', + style: ShadTheme.of(context).textTheme.h3, + ), + Text( + 'Use Flutter DevTools "Debugger" to inspect these objects.', + style: ShadTheme.of(context).textTheme.p, + ), + const SizedBox(height: largeSpacing), + ShadCard( + title: const Text('Breakpoint Target'), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Ticker Running: $_tickerSeconds s', + style: const TextStyle( + fontFamily: 'RobotoMono', + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: denseSpacing), + const Text( + 'Instructions: Open DevTools Debugger and place a breakpoint inside the "_onTick" method in debugger_screen.dart to pause execution repeatedly.', + ), + ], + ), + ), + const SizedBox(height: largeSpacing), + ShadCard( + title: const Text('Data Mutations'), + child: Wrap( + spacing: denseSpacing, + runSpacing: denseSpacing, + children: [ + ShadButton.outline( + onPressed: _addPerson, + child: const Text('Add Person (List)'), + ), + ShadButton.outline( + onPressed: _modifyMap, + child: const Text('Modify Complex Map'), + ), + ShadButton.outline( + onPressed: () => setState(() { + _uniqueIds.add('ID-$_counter'); + _counter++; + }), + child: const Text('AddTo Set'), + ), + ], + ), + ), + ], + ), + ); + } +} + +// --- Custom Objects --- + +class Person { + Person({required this.name, required this.age, this.attributes}); + + final String name; + final int age; + final Map? attributes; + final List friends = []; + + @override + String toString() => '$name ($age)'; +} + +class Node { + Node({required this.id, this.left, this.right}); + + final int id; + Node? left; + Node? right; +} + +base class CustomEntry extends LinkedListEntry { + CustomEntry(this.value); + final int value; + + @override + String toString() => 'Entry($value)'; +} diff --git a/devexp_demos/devtools_companion/lib/src/screens/home/home_screen.dart b/devexp_demos/devtools_companion/lib/src/screens/home/home_screen.dart new file mode 100644 index 0000000..f332755 --- /dev/null +++ b/devexp_demos/devtools_companion/lib/src/screens/home/home_screen.dart @@ -0,0 +1,49 @@ +import 'dart:async'; + +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_markdown/flutter_markdown.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +import '../../shared/ui/theme.dart'; + +class HomeScreen extends StatefulWidget { + const HomeScreen({super.key}); + + @override + State createState() => _HomeScreenState(); +} + +class _HomeScreenState extends State { + String? _readmeContent; + + @override + void initState() { + super.initState(); + unawaited(_loadReadme()); + } + + Future _loadReadme() async { + final content = await rootBundle.loadString('README.md'); + setState(() { + _readmeContent = content; + }); + } + + @override + Widget build(BuildContext context) { + return Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Padding( + padding: const EdgeInsets.all(denseSpacing), + child: ShadCard( + child: _readmeContent == null + ? const Center(child: ShadProgress()) + : Markdown(data: _readmeContent!, shrinkWrap: true), + ), + ), + ], + ); + } +} diff --git a/devexp_demos/devtools_companion/lib/src/screens/inspector/inspector_screen.dart b/devexp_demos/devtools_companion/lib/src/screens/inspector/inspector_screen.dart new file mode 100644 index 0000000..ef9365b --- /dev/null +++ b/devexp_demos/devtools_companion/lib/src/screens/inspector/inspector_screen.dart @@ -0,0 +1,479 @@ +import 'package:flutter/material.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; +import '../../shared/ui/theme.dart'; + +class Meal { + const Meal({ + required this.name, + required this.description, + required this.ingredients, + }); + + final String name; + final String description; + final List ingredients; +} + +const meals = [ + Meal( + name: 'Overflowing Oatmeal', + description: 'A healthy and hearty breakfast.', + ingredients: [ + 'Rolled oats', + 'Milk', + 'Honey', + 'Berries', + 'A very long line of text that will definitely overflow the screen and cause a render overflow error.', + ], + ), + Meal( + name: 'Salad', + description: 'A light and refreshing lunch.', + ingredients: ['Lettuce', 'Tomato', 'Cucumber', 'Dressing'], + ), + Meal( + name: 'Spaghetti Carbonara', + description: 'A classic Italian pasta dish.', + ingredients: [ + 'Spaghetti', + 'Eggs', + 'Pancetta', + 'Parmesan cheese', + 'Black pepper', + ], + ), +]; + +enum CalendarView { month, week } + +class InspectorScreen extends StatefulWidget { + const InspectorScreen({super.key}); + + @override + State createState() => _InspectorScreenState(); +} + +class _InspectorScreenState extends State { + DateTime? _selectedDate; + Meal? _selectedMeal; + CalendarView _calendarView = CalendarView.month; + + void _onDateSelected(DateTime date) { + setState(() { + _selectedDate = date; + }); + } + + void _showCalendar() { + setState(() { + _selectedDate = null; + }); + } + + void _onMealSelected(Meal? meal) { + setState(() { + _selectedMeal = meal; + }); + } + + void _onCalendarViewChanged(CalendarView view) { + setState(() { + _calendarView = view; + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Meal Planner')), + body: Column( + children: [ + Expanded( + child: _selectedDate == null + ? CalendarWidget( + onDateSelected: _onDateSelected, + initialView: _calendarView, + onViewChanged: _onCalendarViewChanged, + ) + : DailyMealPlan( + date: _selectedDate!, + onTap: () { + _showCalendar(); + _onMealSelected(null); + }, + onMealSelected: _onMealSelected, + ), + ), + Expanded(child: RecipeWidget(meal: _selectedMeal)), + ], + ), + ); + } +} + +class DailyMealPlan extends StatelessWidget { + const DailyMealPlan({ + super.key, + required this.date, + required this.onTap, + required this.onMealSelected, + }); + + final DateTime date; + final VoidCallback onTap; + final ValueChanged onMealSelected; + + @override + Widget build(BuildContext context) { + final dayName = DateFormat('EEEE').format(date); + return Stack( + children: [ + Positioned( + top: noPadding, + left: noPadding, + child: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: onTap, + ), + ), + Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Center( + child: Text( + '${date.month}/${date.day}/${date.year} - $dayName', + style: Theme.of(context).textTheme.headlineSmall, + ), + ), + const SizedBox(height: largeSpacing), + Table( + columnWidths: const { + 0: IntrinsicColumnWidth(), + 1: IntrinsicColumnWidth(), + }, + defaultVerticalAlignment: TableCellVerticalAlignment.middle, + children: [ + TableRow( + children: [ + const Padding( + padding: EdgeInsets.only(right: denseSpacing), + child: Text('Breakfast:'), + ), + Align( + alignment: Alignment.centerLeft, + child: ShadButton.ghost( + onPressed: () => onMealSelected(meals[0]), + child: Text(meals[0].name), + ), + ), + ], + ), + TableRow( + children: [ + const Padding( + padding: EdgeInsets.only(right: denseSpacing), + child: Text('Lunch:'), + ), + Align( + alignment: Alignment.centerLeft, + child: ShadButton.ghost( + onPressed: () => onMealSelected(meals[1]), + child: Text(meals[1].name), + ), + ), + ], + ), + TableRow( + children: [ + const Padding( + padding: EdgeInsets.only(right: denseSpacing), + child: Text('Dinner:'), + ), + Align( + alignment: Alignment.centerLeft, + child: ShadButton.ghost( + onPressed: () => onMealSelected(meals[2]), + child: Text(meals[2].name), + ), + ), + ], + ), + ], + ), + ], + ), + ], + ); + } +} + +class CalendarWidget extends StatefulWidget { + const CalendarWidget({ + super.key, + required this.onDateSelected, + required this.initialView, + required this.onViewChanged, + }); + + final ValueChanged onDateSelected; + final CalendarView initialView; + final ValueChanged onViewChanged; + + @override + State createState() => _CalendarWidgetState(); +} + +class _CalendarWidgetState extends State { + late CalendarView _view; + + @override + void initState() { + super.initState(); + _view = widget.initialView; + } + + @override + Widget build(BuildContext context) { + return ShadTabs( + value: _view, + tabs: [ + ShadTab( + value: CalendarView.month, + content: MonthViewWidget(onDateSelected: widget.onDateSelected), + child: const Text('Month'), + ), + ShadTab( + value: CalendarView.week, + content: WeekViewWidget(onDateSelected: widget.onDateSelected), + child: const Text('Week'), + ), + ], + ); + } +} + +class MonthViewWidget extends StatefulWidget { + const MonthViewWidget({super.key, required this.onDateSelected}); + + final ValueChanged onDateSelected; + + @override + State createState() => _MonthViewWidgetState(); +} + +class _MonthViewWidgetState extends State { + late DateTime _currentMonth; + + @override + void initState() { + super.initState(); + final now = DateTime.now(); + _currentMonth = DateTime(now.year, now.month); + } + + void _previousMonth() { + setState(() { + _currentMonth = DateTime(_currentMonth.year, _currentMonth.month - 1); + }); + } + + void _nextMonth() { + setState(() { + _currentMonth = DateTime(_currentMonth.year, _currentMonth.month + 1); + }); + } + + @override + Widget build(BuildContext context) { + final monthName = DateFormat('MMMM yyyy').format(_currentMonth); + final daysInMonth = DateTime( + _currentMonth.year, + _currentMonth.month + 1, + 0, + ).day; + final firstDayOfMonth = DateTime(_currentMonth.year, _currentMonth.month); + final weekdayOfFirstDay = + firstDayOfMonth.weekday; // Monday is 1, Sunday is 7 + + final List calendarItems = []; + for (int i = 1; i < weekdayOfFirstDay; i++) { + calendarItems.add(Container()); + } + + for (int i = 1; i <= daysInMonth; i++) { + final date = DateTime(_currentMonth.year, _currentMonth.month, i); + calendarItems.add( + InkWell( + onTap: () => widget.onDateSelected(date), + child: Center(child: Text('$i')), + ), + ); + } + + return Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + IconButton( + icon: const Icon(Icons.chevron_left), + onPressed: _previousMonth, + ), + Text(monthName), + IconButton( + icon: const Icon(Icons.chevron_right), + onPressed: _nextMonth, + ), + ], + ), + const Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + Text('Mon'), + Text('Tue'), + Text('Wed'), + Text('Thu'), + Text('Fri'), + Text('Sat'), + Text('Sun'), + ], + ), + GridView.count( + physics: const NeverScrollableScrollPhysics(), + shrinkWrap: true, + crossAxisCount: 7, + childAspectRatio: 1.5, + children: calendarItems, + ), + ], + ); + } +} + +class WeekViewWidget extends StatefulWidget { + const WeekViewWidget({super.key, required this.onDateSelected}); + + final ValueChanged onDateSelected; + + @override + State createState() => _WeekViewWidgetState(); +} + +class _WeekViewWidgetState extends State { + late DateTime _currentWeekStart; + + @override + void initState() { + super.initState(); + final now = DateTime.now(); + // Start the week on the Monday of the current week. + _currentWeekStart = now.subtract(Duration(days: now.weekday - 1)); + } + + void _previousWeek() { + setState(() { + _currentWeekStart = _currentWeekStart.subtract(const Duration(days: 7)); + }); + } + + void _nextWeek() { + setState(() { + _currentWeekStart = _currentWeekStart.add(const Duration(days: 7)); + }); + } + + @override + Widget build(BuildContext context) { + final weekEnd = _currentWeekStart.add(const Duration(days: 6)); + final formatter = DateFormat('MMM d'); + final headerText = + '${formatter.format(_currentWeekStart)} - ${formatter.format(weekEnd)}'; + + return Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + IconButton( + icon: const Icon(Icons.chevron_left), + onPressed: _previousWeek, + ), + Text(headerText), + IconButton( + icon: const Icon(Icons.chevron_right), + onPressed: _nextWeek, + ), + ], + ), + const SizedBox(height: denseSpacing), + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: List.generate(7, (dayIndex) { + final date = _currentWeekStart.add(Duration(days: dayIndex)); + return InkWell( + onTap: () => widget.onDateSelected(date), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text(DateFormat('E').format(date)), // Day of week (e.g., Mon) + Text('${date.day}'), + ], + ), + ); + }), + ), + ], + ); + } +} + +class RecipeWidget extends StatelessWidget { + const RecipeWidget({super.key, required this.meal}); + + final Meal? meal; + + @override + Widget build(BuildContext context) { + return ShadCard( + child: Padding( + padding: const EdgeInsets.all(largeSpacing), + child: meal == null + ? Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + 'Select a meal to view its recipe.', + style: ShadTheme.of(context).textTheme.h4, + ), + const SizedBox(height: largeSpacing), + Image.asset('assets/images/habanero.jpg', height: 200), + ], + ), + ) + : Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + meal!.name, + style: Theme.of(context).textTheme.headlineSmall, + ), + const SizedBox(height: denseSpacing), + Text(meal!.description), + const SizedBox(height: largeSpacing), + const Text( + 'Ingredients:', + style: TextStyle(fontWeight: FontWeight.bold), + ), + const SizedBox(height: denseSpacing), + for (final ingredient in meal!.ingredients) + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [const Text('- '), Text(ingredient)], + ), + ], + ), + ), + ); + } +} diff --git a/devexp_demos/devtools_companion/lib/src/screens/logging/logging_screen.dart b/devexp_demos/devtools_companion/lib/src/screens/logging/logging_screen.dart new file mode 100644 index 0000000..0df2cd6 --- /dev/null +++ b/devexp_demos/devtools_companion/lib/src/screens/logging/logging_screen.dart @@ -0,0 +1,234 @@ +import 'package:flutter/material.dart'; +import 'package:logging/logging.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +import '../../shared/ui/theme.dart'; + +final _log = Logger('devtools_companion'); + +class LoggingScreen extends StatefulWidget { + const LoggingScreen({super.key}); + + @override + State createState() => _LoggingScreenState(); +} + +class _LoggingScreenState extends State { + static const String _defaultText = 'Hello world!'; + + late TextEditingController _finestController; + late TextEditingController _finerController; + late TextEditingController _fineController; + late TextEditingController _configController; + late TextEditingController _logController; + late TextEditingController _warnController; + late TextEditingController _severeController; + late TextEditingController _shoutController; + + @override + void initState() { + super.initState(); + _finestController = TextEditingController(text: _defaultText); + _finerController = TextEditingController(text: _defaultText); + _fineController = TextEditingController(text: _defaultText); + _configController = TextEditingController(text: _defaultText); + _logController = TextEditingController(text: _defaultText); + _warnController = TextEditingController(text: _defaultText); + _severeController = TextEditingController(text: _defaultText); + _shoutController = TextEditingController(text: _defaultText); + } + + @override + void dispose() { + _finestController.dispose(); + _finerController.dispose(); + _fineController.dispose(); + _configController.dispose(); + _logController.dispose(); + _warnController.dispose(); + _severeController.dispose(); + _shoutController.dispose(); + super.dispose(); + } + + void _triggerFinestLog() { + final message = _finestController.text; + _log.finest(message); + _showSnackBar('Finest log sent: $message'); + } + + void _triggerFinerLog() { + final message = _finerController.text; + _log.finer(message); + _showSnackBar('Finer log sent: $message'); + } + + void _triggerFineLog() { + final message = _fineController.text; + _log.fine(message); + _showSnackBar('Fine log sent: $message'); + } + + void _triggerConfigLog() { + final message = _configController.text; + _log.config(message); + _showSnackBar('Config log sent: $message'); + } + + void _triggerInfoLog() { + final message = _logController.text; + _log.info(message); + _showSnackBar('Info log sent: $message'); + } + + void _triggerWarningLog() { + final message = _warnController.text; + _log.warning(message); + _showSnackBar('Warning log sent: $message'); + } + + void _triggerSevereLog() { + final message = _severeController.text; + _log.severe(message, Exception(message), StackTrace.current); + _showSnackBar('Severe log sent: $message'); + } + + void _triggerShoutLog() { + final message = _shoutController.text; + _log.shout(message); + _showSnackBar('Shout log sent: $message'); + } + + void _showSnackBar(String text) { + final toast = ShadToast( + description: Text(text), + duration: const Duration(milliseconds: 1200), + ); + ShadToaster.of(context).show(toast); + } + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + return Padding( + padding: const EdgeInsets.all(largeSpacing), + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Trigger Developer Logs', style: theme.textTheme.h2), + const SizedBox(height: extraLargeSpacing), + _LogInputRow( + label: 'log (Finest)', + controller: _finestController, + buttonColor: Colors.lightGreen, + icon: Icons.info_outline, + onPressed: _triggerFinestLog, + ), + const SizedBox(height: largeSpacing), + _LogInputRow( + label: 'log (Finer)', + controller: _finerController, + buttonColor: Colors.green.shade400, + icon: Icons.info_outline, + onPressed: _triggerFinerLog, + ), + const SizedBox(height: largeSpacing), + _LogInputRow( + label: 'log (Fine)', + controller: _fineController, + buttonColor: Colors.green, + icon: Icons.info_outline, + onPressed: _triggerFineLog, + ), + const SizedBox(height: largeSpacing), + _LogInputRow( + label: 'log (Config)', + controller: _configController, + buttonColor: Colors.teal, + icon: Icons.settings, + onPressed: _triggerConfigLog, + ), + const SizedBox(height: largeSpacing), + _LogInputRow( + label: 'log (Info)', + controller: _logController, + buttonColor: Colors.blue, + icon: Icons.info_outline, + onPressed: _triggerInfoLog, + ), + const SizedBox(height: largeSpacing), + _LogInputRow( + label: 'log (Warning)', + controller: _warnController, + buttonColor: Colors.orange, + icon: Icons.warning_amber_rounded, + onPressed: _triggerWarningLog, + ), + const SizedBox(height: largeSpacing), + _LogInputRow( + label: 'log (Severe)', + controller: _severeController, + buttonColor: Colors.red, + icon: Icons.warning_amber_rounded, + onPressed: _triggerSevereLog, + ), + const SizedBox(height: largeSpacing), + _LogInputRow( + label: 'log (Shout)', + controller: _shoutController, + buttonColor: Colors.purple, + icon: Icons.campaign, + onPressed: _triggerShoutLog, + ), + ], + ), + ), + ); + } +} + +class _LogInputRow extends StatelessWidget { + const _LogInputRow({ + required this.label, + required this.controller, + required this.buttonColor, + required this.icon, + required this.onPressed, + }); + + final String label; + final TextEditingController controller; + final Color buttonColor; + final IconData icon; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Expanded( + child: TextField( + controller: controller, + decoration: InputDecoration( + labelText: label, + prefixIcon: Icon(icon, color: buttonColor), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide(color: buttonColor, width: 2), + borderRadius: BorderRadius.circular(8), + ), + ), + ), + ), + const SizedBox(width: defaultSpacing), + SizedBox( + height: 56, + child: ShadButton(onPressed: onPressed, child: const Text('Log')), + ), + ], + ); + } +} diff --git a/devexp_demos/devtools_companion/lib/src/screens/memory/memory_screen.dart b/devexp_demos/devtools_companion/lib/src/screens/memory/memory_screen.dart new file mode 100644 index 0000000..b257ad2 --- /dev/null +++ b/devexp_demos/devtools_companion/lib/src/screens/memory/memory_screen.dart @@ -0,0 +1,281 @@ +import 'dart:async'; +import 'dart:isolate'; +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; +import '../../shared/ui/theme.dart'; + +const _chars = 'AaBbCcDdEeFfGgHhIiJjKkLlMmNnOoPpQqRrSsTtUuVvWwXxYyZz1234567890'; + +void _generateObjectsIsolate(Map args) async { + final sendPort = args['sendPort'] as SendPort; + final objectCount = args['objectCount'] as int; + final minSize = args['minSize'] as int; + final maxSize = args['maxSize'] as int; + final random = Random(); + final allocatedObjects = []; + final batch = []; + final stopwatch = Stopwatch()..start(); + + for (var i = 0; i < objectCount; i++) { + final length = random.nextInt(maxSize - minSize + 1) + minSize; + final randomString = String.fromCharCodes( + Iterable.generate( + length, + (_) => _chars.codeUnitAt(random.nextInt(_chars.length)), + ), + ); + + allocatedObjects.add(randomString); + batch.add(randomString); + if (stopwatch.elapsedMilliseconds > 50 || batch.length >= 500) { + sendPort.send({ + 'type': 'progress', + 'count': i + 1, + 'batch': List.from(batch), + }); + batch.clear(); + await Future.delayed(Duration.zero); + stopwatch.reset(); + } + } + if (batch.isNotEmpty) { + sendPort.send({ + 'type': 'progress', + 'count': allocatedObjects.length, + 'batch': List.from(batch), + }); + } + sendPort.send({ + 'type': 'complete', + 'count': allocatedObjects.length, + 'batch': [], + }); +} + +class MemoryScreen extends StatefulWidget { + const MemoryScreen({super.key}); + + @override + State createState() => _MemoryScreenState(); +} + +class _MemoryScreenState extends State { + late final TextEditingController _objectCountController; + late final TextEditingController _minSizeController; + late final TextEditingController _maxSizeController; + final _allocatedObjects = []; + bool _isGenerating = false; + int _generationProgress = 0; + Isolate? _isolate; + ReceivePort? _receivePort; + + @override + void initState() { + super.initState(); + _objectCountController = TextEditingController(text: '100000'); + _minSizeController = TextEditingController(text: '1000'); + _maxSizeController = TextEditingController(text: '100000'); + } + + @override + void dispose() { + _objectCountController.dispose(); + _minSizeController.dispose(); + _maxSizeController.dispose(); + _isolate?.kill(priority: Isolate.immediate); + _receivePort?.close(); + super.dispose(); + } + + Future _allocateObjects() async { + setState(() { + _isGenerating = true; + _generationProgress = 0; + }); + + final objectCount = int.tryParse(_objectCountController.text) ?? 100000; + final minSize = int.tryParse(_minSizeController.text) ?? 1000; + final maxSize = int.tryParse(_maxSizeController.text) ?? 100000; + + if (minSize > maxSize) { + ShadToaster.of(context).show( + const ShadToast( + title: Text('Error'), + description: Text('Min size cannot be greater than max size.'), + ), + ); + setState(() { + _isGenerating = false; + }); + return; + } + + _receivePort = ReceivePort(); + _isolate = await Isolate.spawn(_generateObjectsIsolate, { + 'sendPort': _receivePort!.sendPort, + 'objectCount': objectCount, + 'minSize': minSize, + 'maxSize': maxSize, + }); + + _receivePort!.listen((message) { + if (message is Map) { + switch (message['type']) { + case 'progress': + final newObjects = + (message['batch'] as List? ?? []).cast(); + if (newObjects.isNotEmpty) { + _allocatedObjects.addAll(newObjects); + } + setState(() { + _generationProgress = message['count'] as int; + }); + break; + case 'complete': + final newObjects = + (message['batch'] as List? ?? []).cast(); + if (newObjects.isNotEmpty) { + _allocatedObjects.addAll(newObjects); + } + ShadToaster.of(context).show( + ShadToast( + description: Text( + 'Allocated ${_allocatedObjects.length} string objects.', + ), + ), + ); + _stopGeneration(); + break; + } + } + }); + } + + void _stopGeneration() { + _isolate?.kill(priority: Isolate.immediate); + _receivePort?.close(); + setState(() { + _isolate = null; + _receivePort = null; + _isGenerating = false; + _generationProgress = 0; + }); + } + + void _clearObjects() { + _allocatedObjects.clear(); + setState(() {}); + ShadToaster.of(context).show( + const ShadToast( + description: Text('Cleared allocated objects. Ready for GC.'), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: ListView( + padding: const EdgeInsets.all(defaultSpacing), + children: [ + Align( + alignment: Alignment.topCenter, + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 480), + child: ShadCard( + title: const Text('Memory Tests'), + description: Text( + _isGenerating + ? 'Generating objects... $_generationProgress so far.' + : 'Use these tools to test memory management in your application.\n' + 'Currently holding onto ${_allocatedObjects.length} string objects.', + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (_isGenerating) ...[ + const ShadProgress(), + const Padding( + padding: EdgeInsets.all(defaultSpacing), + ), + const Padding( + padding: EdgeInsets.all(defaultSpacing), + ), + Semantics( + label: 'stop generation', + button: true, + child: ShadButton.destructive( + onPressed: _stopGeneration, + child: const Text('Stop Generation'), + ), + ), + ] else ...[ + Semantics( + label: 'allocate objects', + button: true, + child: ShadButton( + onPressed: _allocateObjects, + child: const Text('Allocate Objects'), + ), + ), + ], + const Padding( + padding: EdgeInsets.all(defaultSpacing), + ), + Semantics( + label: 'number of objects', + child: ShadInputFormField( + key: const ValueKey('numberOfObjectsField'), + controller: _objectCountController, + label: const Text('Number of Objects'), + keyboardType: TextInputType.number, + ), + ), + const Padding( + padding: EdgeInsets.all(defaultSpacing), + ), + Semantics( + label: 'minimum string size', + child: ShadInputFormField( + key: const ValueKey('minStringSizeField'), + controller: _minSizeController, + label: const Text('Min String Size'), + keyboardType: TextInputType.number, + ), + ), + const Padding( + padding: EdgeInsets.all(defaultSpacing), + ), + Semantics( + label: 'maximum string size', + child: ShadInputFormField( + key: const ValueKey('maxStringSizeField'), + controller: _maxSizeController, + label: const Text('Max String Size'), + keyboardType: TextInputType.number, + ), + ), + const Padding( + padding: EdgeInsets.all(defaultSpacing), + ), + Semantics( + label: 'clear allocated objects', + button: true, + child: ShadButton.destructive( + onPressed: _clearObjects, + child: const Text('Clear Allocated Objects'), + ), + ), + ], + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/devexp_demos/devtools_companion/lib/src/screens/network/http_client.dart b/devexp_demos/devtools_companion/lib/src/screens/network/http_client.dart new file mode 100644 index 0000000..08a681f --- /dev/null +++ b/devexp_demos/devtools_companion/lib/src/screens/network/http_client.dart @@ -0,0 +1,354 @@ +import 'dart:async'; +import 'dart:io' as io; + +import 'package:dio/dio.dart'; +import 'package:http/http.dart' as http; + +import 'http_server.dart'; + +/// The options for what the process should be after a request is sent; whether +/// the request should be completed by the server, or not, or cancelled by the +/// client. +enum CompletionType { + completes('Completes'), + doesNotComplete('Does not complete'), + isCancelled('Is cancelled before completes'); + + const CompletionType(this.text); + + final String text; +} + +/// The HTTP client code that makes HTTP requests and logs various things. +class HttpClient { + final io.HttpClient _client = io.HttpClient(); + + final _dio = Dio(); + + /// Sends an HTTP GET request using `dart:io`, and awaits a response. + void get({ + required Logger networkNotifier, + required bool requestHasBody, + required bool responseHasBody, + required int responseCode, + CompletionType completionType = CompletionType.completes, + }) { + _ioRequest( + method: 'GET', + networkNotifier: networkNotifier, + requestHasBody: requestHasBody, + responseHasBody: responseHasBody, + responseCode: responseCode, + completionType: completionType, + openRequest: (uri) => _client.getUrl(uri), + ); + } + + /// Sends an HTTP POST request using `dart:io`, and awaits a response. + void post({ + required Logger networkNotifier, + required bool requestHasBody, + required bool responseHasBody, + required int responseCode, + CompletionType completionType = CompletionType.completes, + }) { + _ioRequest( + method: 'POST', + networkNotifier: networkNotifier, + requestHasBody: requestHasBody, + responseHasBody: responseHasBody, + responseCode: responseCode, + completionType: completionType, + openRequest: (uri) => _client.postUrl(uri), + ); + } + + /// Sends an HTTP PUT request using `dart:io`, and awaits a response. + void put({ + required Logger networkNotifier, + required bool requestHasBody, + required bool responseHasBody, + required int responseCode, + CompletionType completionType = CompletionType.completes, + }) { + _ioRequest( + method: 'PUT', + networkNotifier: networkNotifier, + requestHasBody: requestHasBody, + responseHasBody: responseHasBody, + responseCode: responseCode, + completionType: completionType, + openRequest: (uri) => _client.putUrl(uri), + ); + } + + /// Sends an HTTP DELETE request using `dart:io`, and awaits a response. + void delete({ + required Logger networkNotifier, + required bool requestHasBody, + required bool responseHasBody, + required int responseCode, + CompletionType completionType = CompletionType.completes, + }) { + _ioRequest( + method: 'DELETE', + networkNotifier: networkNotifier, + requestHasBody: requestHasBody, + responseHasBody: responseHasBody, + responseCode: responseCode, + completionType: completionType, + openRequest: (uri) => _client.deleteUrl(uri), + ); + } + + /// Sends a request with the dart:io library as per the parameters. + void _ioRequest({ + required String method, + required Logger networkNotifier, + required bool requestHasBody, + required bool responseHasBody, + required int responseCode, + required CompletionType completionType, + required Future Function(Uri) openRequest, + }) async { + final uri = _computeUri( + responseHasBody: responseHasBody, + completionType: completionType, + responseCode: responseCode, + ); + networkNotifier('Sending $method to $uri...'); + final request = await openRequest(uri); + networkNotifier('Sent $method.'); + if (requestHasBody) { + request.write('Request Body'); + } + if (completionType == CompletionType.isCancelled) { + Timer(const Duration(seconds: 1), () { + networkNotifier('$method aborted.'); + request.abort(); + }); + } + try { + final response = await request.close(); + networkNotifier('Received $method response: $response'); + } catch (e) { + networkNotifier('$method caught exception: $e'); + } + } + + /// Sends an HTTP GET request using the http package, and awaits a response. + void packageHttpGet({ + required Logger networkNotifier, + // Unused. + required bool requestHasBody, + required bool responseHasBody, + required int responseCode, + CompletionType completionType = CompletionType.completes, + }) { + _packageHttpRequest( + method: 'GET', + networkNotifier: networkNotifier, + responseHasBody: responseHasBody, + responseCode: responseCode, + completionType: completionType, + action: (client, uri) => client.get(uri), + ); + } + + /// Sends an HTTP POST request using the http package, and awaits a response. + void packageHttpPost({ + required Logger networkNotifier, + required bool requestHasBody, + required bool responseHasBody, + required int responseCode, + CompletionType completionType = CompletionType.completes, + }) { + _packageHttpRequest( + method: 'POST', + networkNotifier: networkNotifier, + responseHasBody: responseHasBody, + responseCode: responseCode, + completionType: completionType, + action: (client, uri) => client.post( + uri, + body: requestHasBody ? {'name': 'doodle', 'color': 'blue'} : null, + ), + ); + } + + /// Sends a streamed HTTP POST request using the http package, and awaits a + /// response. + void packageHttpPostStreamed({ + required Logger networkNotifier, + required bool requestHasBody, + required bool responseHasBody, + required int responseCode, + CompletionType completionType = CompletionType.completes, + }) { + _packageHttpRequest( + method: 'streamed POST', + networkNotifier: networkNotifier, + responseHasBody: responseHasBody, + responseCode: responseCode, + completionType: completionType, + action: (client, uri) async { + final request = http.StreamedRequest('POST', uri) + ..contentLength = 20 + ..sink.add([11, 12, 13, 14, 15, 16, 17, 18, 19, 20]) + ..sink.add([21, 22, 23, 24, 25, 26, 27, 28, 29, 30]); + await request.sink.close(); + return client.send(request); + }, + ); + } + + /// Sends an HTTP DELETE request using the http package, and awaits a + /// response. + void packageHttpDelete({ + required Logger networkNotifier, + required bool requestHasBody, + required bool responseHasBody, + required int responseCode, + CompletionType completionType = CompletionType.completes, + }) { + _packageHttpRequest( + method: 'DELETE', + networkNotifier: networkNotifier, + responseHasBody: responseHasBody, + responseCode: responseCode, + completionType: completionType, + action: (client, uri) => client.delete( + uri, + body: requestHasBody ? {'name': 'doodle', 'color': 'blue'} : null, + ), + ); + } + + /// Sends a request with the http package as per the parameters. + void _packageHttpRequest({ + required String method, + required Logger networkNotifier, + required bool responseHasBody, + required int responseCode, + required CompletionType completionType, + required Future Function(http.Client, Uri) action, + }) async { + final uri = _computeUri( + responseHasBody: responseHasBody, + completionType: completionType, + responseCode: responseCode, + ); + networkNotifier('Sending package:http $method to $uri...'); + final client = http.Client(); + if (completionType == CompletionType.isCancelled) { + Timer(const Duration(seconds: 1), () { + networkNotifier('package:http $method client closed.'); + client.close(); + }); + } + try { + final response = await action(client, uri); + networkNotifier('Received package:http $method response: $response'); + } catch (e) { + networkNotifier('package:http $method caught exception: $e'); + } finally { + client.close(); + } + } + + void dioGet({ + required Logger networkNotifier, + required bool requestHasBody, + required bool responseHasBody, + required int responseCode, + CompletionType completionType = CompletionType.completes, + }) { + _dioRequest( + method: 'GET', + networkNotifier: networkNotifier, + responseHasBody: responseHasBody, + responseCode: responseCode, + completionType: completionType, + action: (cancelToken, uri) => _dio.getUri(uri, cancelToken: cancelToken), + ); + } + + void dioPost({ + required Logger networkNotifier, + required bool requestHasBody, + required bool responseHasBody, + required int responseCode, + CompletionType completionType = CompletionType.completes, + }) { + _dioRequest( + method: 'POST', + networkNotifier: networkNotifier, + responseHasBody: responseHasBody, + responseCode: responseCode, + completionType: completionType, + action: (cancelToken, uri) => _dio.postUri( + uri, + data: requestHasBody ? {'a': 'b', 'c': 'd'} : null, + cancelToken: cancelToken, + ), + ); + } + + /// Sends a request with the dio package as per the parameters. + void _dioRequest({ + required String method, + required Logger networkNotifier, + required bool responseHasBody, + required int responseCode, + required CompletionType completionType, + required Future Function(CancelToken, Uri) action, + }) async { + final uri = _computeUri( + responseHasBody: responseHasBody, + completionType: completionType, + responseCode: responseCode, + ); + networkNotifier('Sending Dio $method to $uri...'); + final cancelToken = CancelToken(); + if (completionType == CompletionType.isCancelled) { + Timer(const Duration(seconds: 1), () { + networkNotifier('Dio $method cancelled.'); + cancelToken.cancel('User cancelled request'); + }); + } + try { + final response = await action(cancelToken, uri); + networkNotifier( + 'Recived Dio $method response; headers: ${response.headers}', + ); + } on DioException catch (e) { + if (CancelToken.isCancel(e)) { + networkNotifier('Dio $method caught cancellation: ${e.message}'); + } else { + networkNotifier('Dio $method caught DioException: $e'); + } + } catch (e) { + networkNotifier('Dio $method caught exception: $e'); + } + } + + /// Computes a [Uri] from various configuration. + /// + /// The configuration is embedded into the URI, conveying the configuration to + /// the HTTP server. + Uri _computeUri({ + required bool responseHasBody, + required CompletionType completionType, + required int responseCode, + }) => Uri.http( + '127.0.0.1:$httpServerPort', + [ + '/', + if (responseHasBody) 'responseHasBody/', + if (completionType == CompletionType.completes) 'complete/', + ].join(), + {'responseCode': '$responseCode'}, + ); +} + +typedef Logger = void Function(String); diff --git a/devexp_demos/devtools_companion/lib/src/screens/network/http_server.dart b/devexp_demos/devtools_companion/lib/src/screens/network/http_server.dart new file mode 100644 index 0000000..f472437 --- /dev/null +++ b/devexp_demos/devtools_companion/lib/src/screens/network/http_server.dart @@ -0,0 +1,45 @@ +import 'dart:io' as io; + +/// The HttpServer used for all HTTP requests. +class HttpServer { + const HttpServer(this._server); + + final io.HttpServer _server; + + static Future create() async { + final ioServer = await io.HttpServer.bind( + io.InternetAddress.loopbackIPv4, + httpServerPort, + ); + + ioServer.listen((request) async { + final path = request.uri.path; + final queryParameters = request.uri.queryParameters; + final responseCode = + int.tryParse(queryParameters['responseCode'] ?? '200') ?? 200; + request.response.statusCode = responseCode; + if (path.contains('responseHasBody/')) { + request.response.headers.contentType = io.ContentType( + 'application', + 'json', + charset: 'utf-8', + ); + request.response.write('{"response body":7}'); + await request.response.flush(); + } + if (path.contains('complete/')) { + // Wait 3 seconds before completing the response, in order to allow a + // request to be cancelled. + await Future.delayed(const Duration(seconds: 3)); + await request.response.close(); + } + }); + + return HttpServer(ioServer); + } + + /// The port that the [io.HttpServer] ultimately bound to. + int get port => _server.port; +} + +const httpServerPort = 8888; diff --git a/devexp_demos/devtools_companion/lib/src/screens/network/network_screen.dart b/devexp_demos/devtools_companion/lib/src/screens/network/network_screen.dart new file mode 100644 index 0000000..8b7c378 --- /dev/null +++ b/devexp_demos/devtools_companion/lib/src/screens/network/network_screen.dart @@ -0,0 +1,361 @@ +import 'dart:async'; + +import 'package:flutter/material.dart' hide Checkbox, TextFormField; +import 'package:shadcn_ui/shadcn_ui.dart'; + +import '../../shared/ui/theme.dart'; +import 'http_client.dart'; +import 'http_server.dart'; + +class NetworkScreen extends StatefulWidget { + const NetworkScreen({super.key}); + + @override + State createState() => _NetworkScreenState(); +} + +class _NetworkScreenState extends State { + final _httpClient = HttpClient(); + + final Future _server = HttpServer.create(); + + @override + Widget build(BuildContext context) { + // Initialize the global HTTP server. + unawaited(_server); + return SingleChildScrollView( + child: RequestTable( + httpClient: _httpClient, + networkNotifier: _networkNotifier, + ), + ); + } + + void _networkNotifier(String text) { + final toast = ShadToast( + alignment: Alignment.topCenter, + description: Text(text), + backgroundColor: ShadTheme.of(context).colorScheme.accent, + ); + ShadToaster.of(context).show(toast); + } +} + +class _RequestSettings { + _RequestSettings({ + required this.type, + required this.action, + this.requestHasBody = false, + this.requestCanHaveBody = true, + }); + + final _RequestType type; + final void Function({ + required Logger networkNotifier, + required bool requestHasBody, + required int responseCode, + required bool responseHasBody, + CompletionType completionType, + }) + action; + + /// `null` means disabled. + bool? requestHasBody; + bool requestCanHaveBody; + int responseCode = 200; + bool responseHasBody = true; + CompletionType completionType = CompletionType.completes; + bool shouldRepeat = false; +} + +enum _RequestType { + httpGet('dart:io GET'), + httpPost('dart:io POST'), + httpPut('dart:io PUT'), + httpDelete('dart:io DELETE'), + packageHttpGet('package:http GET'), + packageHttpPost('package:http POST'), + packageHttpPostStreamed('package:http POST (streamed)'), + packageHttpDelete('package:http DELETE'), + dioGet('Dio GET'), + dioPost('Dio POST'); + // TODO: WebSocket + // TODO: cronet_http - https://pub.dev/packages/cronet_http + // TODO: ok_http - https://pub.dev/packages/ok_http + + const _RequestType(this.text); + + final String text; +} + +typedef Logger = void Function(String); + +class RequestTable extends StatefulWidget { + const RequestTable({ + required HttpClient httpClient, + required Logger networkNotifier, + super.key, + }) : _httpClient = httpClient, + _networkNotifier = networkNotifier; + + final HttpClient _httpClient; + final Logger _networkNotifier; + + @override + State createState() => _RequestTableState(); +} + +class _RequestTableState extends State { + final _repeatingTimers = <_RequestSettings, Timer>{}; + + @override + void dispose() { + for (final timer in _repeatingTimers.values) { + timer.cancel(); + } + super.dispose(); + } + + late List<_RequestSettings> settingsList = [ + _RequestSettings( + type: _RequestType.httpGet, + action: widget._httpClient.get, + requestHasBody: null, + requestCanHaveBody: false, + ), + _RequestSettings( + type: _RequestType.httpPost, + action: widget._httpClient.post, + ), + _RequestSettings( + type: _RequestType.httpPut, + action: widget._httpClient.put, + ), + _RequestSettings( + type: _RequestType.httpDelete, + action: widget._httpClient.delete, + requestHasBody: null, + requestCanHaveBody: false, + ), + _RequestSettings( + type: _RequestType.packageHttpGet, + action: widget._httpClient.packageHttpGet, + ), + _RequestSettings( + type: _RequestType.packageHttpPost, + action: widget._httpClient.packageHttpPost, + ), + _RequestSettings( + type: _RequestType.packageHttpPostStreamed, + action: widget._httpClient.packageHttpPostStreamed, + requestHasBody: null, + ), + _RequestSettings( + type: _RequestType.packageHttpDelete, + action: widget._httpClient.packageHttpDelete, + ), + _RequestSettings( + type: _RequestType.dioGet, + action: widget._httpClient.dioGet, + ), + _RequestSettings( + type: _RequestType.dioPost, + action: widget._httpClient.dioPost, + ), + ]; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + for (var i = 0; i < settingsList.length; i++) ...[ + () { + final settings = settingsList[i]; + return ShadCard( + child: Padding( + padding: const EdgeInsets.all(densePadding), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + settings.type.text, + style: ShadTheme.of(context).textTheme.h4, + ), + ), + Row( + children: [ + Text( + 'Code: ', + style: ShadTheme.of(context).textTheme.p, + ), + SizedBox( + width: 60, + child: ShadInput( + initialValue: '200', + keyboardType: TextInputType.number, + padding: const EdgeInsets.symmetric( + horizontal: defaultPadding, + vertical: densePadding, + ), + onChanged: (value) { + setState(() { + settings.responseCode = + int.tryParse(value) ?? 200; + }); + }, + ), + ), + ], + ), + ], + ), + const SizedBox(height: denseSpacing), + const Divider(), + GridView.count( + crossAxisCount: 2, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + childAspectRatio: 4.5, + children: [ + _buildCheckboxRow( + label: 'Request Body', + value: + settings.requestHasBody ?? + settings.requestCanHaveBody, + enabled: settings.requestHasBody != null, + onChanged: (val) => + setState(() => settings.requestHasBody = val), + ), + _buildCheckboxRow( + label: 'Response Body', + value: settings.responseHasBody, + onChanged: (val) => + setState(() => settings.responseHasBody = val), + ), + _buildCheckboxRow( + label: 'Repeats', + value: settings.shouldRepeat, + onChanged: (val) => + setState(() => settings.shouldRepeat = val), + ), + ], + ), + const SizedBox(height: denseSpacing), + Row( + children: [ + Text( + 'Completion: ', + style: ShadTheme.of(context).textTheme.small, + ), + Expanded( + child: ShadSelect( + initialValue: settings.completionType, + onChanged: (val) => setState( + () => settings.completionType = val!, + ), + options: + CompletionType.values + .map( + (e) => ShadOption( + value: e, + child: Text(e.text), + ), + ) + .toList(), + selectedOptionBuilder: + (context, value) => Text(value.text), + ), + ), + ], + ), + const SizedBox(height: denseSpacing), + SizedBox( + width: double.infinity, + child: ShadButton( + onPressed: () => _handleAction(settings), + child: Text( + settings.shouldRepeat + ? (_repeatingTimers.containsKey(settings) + ? 'Stop' + : 'Start Loop') + : 'Send Request', + ), + ), + ), + ], + ), + ), + ); + }(), + if (i < settingsList.length - 1) + const SizedBox(height: largeSpacing), + ], + ], + ), + ); + } + + Widget _buildCheckboxRow({ + required String label, + required bool value, + required Function(bool) onChanged, + bool enabled = true, + }) { + return Row( + children: [ + ShadCheckbox(value: value, onChanged: enabled ? onChanged : null), + const SizedBox(width: 8), + Expanded( + child: Text( + label, + style: TextStyle( + color: enabled ? null : Theme.of(context).disabledColor, + fontSize: 12, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ); + } + + void _handleAction(_RequestSettings settings) { + if (settings.shouldRepeat) { + if (_repeatingTimers.containsKey(settings)) { + // Stop the timer. + _repeatingTimers[settings]!.cancel(); + setState(() { + _repeatingTimers.remove(settings); + }); + } else { + // Start the timer. + final timer = Timer.periodic( + const Duration(seconds: 1), + (timer) => _runAction(settings), + ); + setState(() { + _repeatingTimers[settings] = timer; + }); + } + } else { + // Just run once. + _runAction(settings); + } + } + + void _runAction(_RequestSettings settings) { + settings.action( + networkNotifier: widget._networkNotifier, + requestHasBody: settings.requestHasBody ?? false, + responseCode: settings.responseCode, + responseHasBody: settings.responseHasBody, + completionType: settings.completionType, + ); + } +} diff --git a/devexp_demos/devtools_companion/lib/src/screens/performance/data/fruits.dart b/devexp_demos/devtools_companion/lib/src/screens/performance/data/fruits.dart new file mode 100644 index 0000000..039d79f --- /dev/null +++ b/devexp_demos/devtools_companion/lib/src/screens/performance/data/fruits.dart @@ -0,0 +1,145 @@ +import 'dart:convert'; + +class Fruit { + const Fruit({required this.title, required this.imageUrl}); + + factory Fruit.fromJson(Map json) { + return Fruit( + title: json['title'] as String, + imageUrl: json['image_path'] as String, + ); + } + + static List parseJson(String jsonStr) { + final json = jsonDecode(jsonStr) as List; + return json.map((item) => Fruit.fromJson(item)).toList(); + } + + final String title; + final String imageUrl; +} + +const fruitDataStr = ''' +[ + { + "title": "Apple", + "image_path": "assets/images/apple.jpg" + }, + { + "title": "Artichoke", + "image_path": "assets/images/artichoke.jpg" + }, + { + "title": "Asparagus", + "image_path": "assets/images/asparagus.jpg" + }, + { + "title": "Avocado", + "image_path": "assets/images/avocado.jpg" + }, + { + "title": "Blackberry", + "image_path": "assets/images/blackberry.jpg" + }, + { + "title": "Cantaloupe", + "image_path": "assets/images/cantaloupe.jpg" + }, + { + "title": "Cauliflower", + "image_path": "assets/images/cauliflower.jpg" + }, + { + "title": "Endive", + "image_path": "assets/images/endive.jpg" + }, + { + "title": "Fig", + "image_path": "assets/images/fig.jpg" + }, + { + "title": "Grape", + "image_path": "assets/images/grape.jpg" + }, + { + "title": "Green Bell Pepper", + "image_path": "assets/images/green_bell_pepper.jpg" + }, + { + "title": "Habanero", + "image_path": "assets/images/habanero.jpg" + }, + { + "title": "Kale", + "image_path": "assets/images/kale.jpg" + }, + { + "title": "Kiwi", + "image_path": "assets/images/kiwi.jpg" + }, + { + "title": "Lemon", + "image_path": "assets/images/lemon.jpg" + }, + { + "title": "Lime", + "image_path": "assets/images/lime.jpg" + }, + { + "title": "Mango", + "image_path": "assets/images/mango.jpg" + }, + { + "title": "Mushroom", + "image_path": "assets/images/mushroom.jpg" + }, + { + "title": "Nectarine", + "image_path": "assets/images/nectarine.jpg" + }, + { + "title": "Persimmon", + "image_path": "assets/images/persimmon.jpg" + }, + { + "title": "Plum", + "image_path": "assets/images/plum.jpg" + }, + { + "title": "Potato", + "image_path": "assets/images/potato.jpg" + }, + { + "title": "Radicchio", + "image_path": "assets/images/radicchio.jpg" + }, + { + "title": "Radish", + "image_path": "assets/images/radish.jpg" + }, + { + "title": "Squash", + "image_path": "assets/images/squash.jpg" + }, + { + "title": "Strawberry", + "image_path": "assets/images/strawberry.jpg" + }, + { + "title": "Tangelo", + "image_path": "assets/images/tangelo.jpg" + }, + { + "title": "Tomato", + "image_path": "assets/images/tomato.jpg" + }, + { + "title": "Watermelon", + "image_path": "assets/images/watermelon.jpg" + }, + { + "title": "Orange Bell Pepper", + "image_path": "assets/images/orange_bell_pepper.jpg" + } +] +'''; diff --git a/devexp_demos/devtools_companion/lib/src/screens/performance/fruit_details.dart b/devexp_demos/devtools_companion/lib/src/screens/performance/fruit_details.dart new file mode 100644 index 0000000..cdfcb71 --- /dev/null +++ b/devexp_demos/devtools_companion/lib/src/screens/performance/fruit_details.dart @@ -0,0 +1,34 @@ +import 'package:flutter/widgets.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +import '../../shared/ui/theme.dart'; +import 'data/fruits.dart'; + +class FruitDetailsScreen extends StatelessWidget { + const FruitDetailsScreen({super.key, required this.fruit}); + + final Fruit fruit; + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + + return Padding( + padding: const EdgeInsets.all(defaultSpacing), + child: ShadCard( + backgroundColor: theme.colorScheme.secondary, + padding: const EdgeInsets.all(noPadding), + footer: Padding( + padding: const EdgeInsets.symmetric(vertical: densePadding), + child: Center( + child: Text( + fruit.title, + style: theme.textTheme.p.copyWith(fontStyle: FontStyle.italic), + ), + ), + ), + child: Image.asset(fruit.imageUrl), + ), + ); + } +} diff --git a/devexp_demos/devtools_companion/lib/src/screens/performance/fruit_list.dart b/devexp_demos/devtools_companion/lib/src/screens/performance/fruit_list.dart new file mode 100644 index 0000000..95e36f4 --- /dev/null +++ b/devexp_demos/devtools_companion/lib/src/screens/performance/fruit_list.dart @@ -0,0 +1,89 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +import '../../scaffold/router.dart'; +import '../../shared/ui/theme.dart'; +import 'data/fruits.dart'; + +class FruitsList extends StatefulWidget { + const FruitsList({super.key}); + + @override + State createState() => _FruitsListState(); +} + +class _FruitsListState extends State { + late Future> _fruitData; + + @override + void initState() { + super.initState(); + _fruitData = Future.value(Fruit.parseJson(fruitDataStr)); + } + + @override + Widget build(BuildContext context) { + return FutureBuilder>( + future: _fruitData, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + if (snapshot.hasError) { + return Center(child: Text('Error: ${snapshot.error}')); + } + if (snapshot.hasData) { + return _FruitTiles(fruitData: snapshot.data!); + } + return const Center(child: Text('No fruits found.')); + }, + ); + } +} + +class _FruitTiles extends StatelessWidget { + const _FruitTiles({required this.fruitData}); + + final List fruitData; + + static const _imageSize = 60.0; + + @override + Widget build(BuildContext context) { + return ListView.builder( + itemCount: fruitData.length * 3, + itemExtent: _imageSize + defaultSpacing, + itemBuilder: (context, index) { + final fruit = fruitData[index % fruitData.length]; + return Padding( + padding: const EdgeInsets.all(densePadding), + child: ListTile( + onTap: () => Navigator.of( + context, + ).pushNamed(AppRoute.performanceDetails.path, arguments: fruit), + leading: ClipRRect( + borderRadius: BorderRadius.circular(4.0), + child: Image.asset( + fruit.imageUrl, + width: _imageSize, + height: _imageSize, + cacheWidth: 150, + cacheHeight: 150, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return Container( + width: _imageSize, + height: _imageSize, + color: Colors.grey[200], + child: const Icon(Icons.broken_image, color: Colors.grey), + ); + }, + ), + ), + title: Text(fruit.title), + ), + ); + }, + ); + } +} diff --git a/devexp_demos/devtools_companion/lib/src/screens/performance/performance_controller.dart b/devexp_demos/devtools_companion/lib/src/screens/performance/performance_controller.dart new file mode 100644 index 0000000..703a2fc --- /dev/null +++ b/devexp_demos/devtools_companion/lib/src/screens/performance/performance_controller.dart @@ -0,0 +1,12 @@ +import 'package:flutter/widgets.dart'; + +class PerformanceScreenController { + PerformanceScreenController(); + final uiJankDurationMs = ValueNotifier(0.0); + final rasterJankIntensity = ValueNotifier(0.0); + + void dispose() { + uiJankDurationMs.dispose(); + rasterJankIntensity.dispose(); + } +} diff --git a/devexp_demos/devtools_companion/lib/src/screens/performance/performance_saboteur.dart b/devexp_demos/devtools_companion/lib/src/screens/performance/performance_saboteur.dart new file mode 100644 index 0000000..9a33099 --- /dev/null +++ b/devexp_demos/devtools_companion/lib/src/screens/performance/performance_saboteur.dart @@ -0,0 +1,101 @@ +import 'dart:async'; +import 'dart:ui'; + +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; + +class RasterThreadSaboteur extends StatelessWidget { + const RasterThreadSaboteur({super.key, required this.intensity}); + + final int intensity; + + @override + Widget build(BuildContext context) { + if (intensity == 0) return const SizedBox.shrink(); + + Widget child = const SizedBox.shrink(); + + for (int i = 0; i < intensity * 5; i++) { + child = Stack( + fit: StackFit.expand, + children: [ + child, + // Opacity + Clip + Blur = Expensive Rasterization + Opacity( + opacity: 0.01, + child: ClipRect( + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 5, sigmaY: 5), + child: Container(color: Colors.white.withAlpha(10)), + ), + ), + ), + ], + ); + } + + // Make it invisible. + return Positioned.fill(child: IgnorePointer(child: child)); + } +} + +class UiThreadSaboteur extends StatefulWidget { + const UiThreadSaboteur({super.key, required this.jankMs}); + final int jankMs; + + @override + State createState() => _UiThreadSaboteurState(); +} + +class _UiThreadSaboteurState extends State + with SingleTickerProviderStateMixin { + late final Ticker _ticker; + + @override + void initState() { + super.initState(); + _ticker = createTicker((_) => _induceJank()); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _updateTickerState(); + } + + @override + void didUpdateWidget(covariant UiThreadSaboteur oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.jankMs != widget.jankMs) { + _updateTickerState(); + } + } + + void _updateTickerState() { + final shouldRun = TickerMode.of(context) && widget.jankMs > 0; + if (shouldRun && !_ticker.isActive) { + unawaited(_ticker.start()); + } else if (!shouldRun && _ticker.isActive) { + _ticker.stop(); + } + } + + void _induceJank() { + final stallMs = widget.jankMs; + if (stallMs <= 0) return; + + final stopwatch = Stopwatch()..start(); + while (stopwatch.elapsedMilliseconds < stallMs) { + // This busy-wait loop intentionally blocks the UI thread to induce jank. + } + } + + @override + void dispose() { + _ticker.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => const SizedBox.shrink(); +} diff --git a/devexp_demos/devtools_companion/lib/src/screens/performance/performance_screen.dart b/devexp_demos/devtools_companion/lib/src/screens/performance/performance_screen.dart new file mode 100644 index 0000000..c7a27b1 --- /dev/null +++ b/devexp_demos/devtools_companion/lib/src/screens/performance/performance_screen.dart @@ -0,0 +1,67 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +import '../../shared/ui/theme.dart'; +import 'fruit_list.dart'; +import 'performance_controller.dart'; +import 'performance_saboteur.dart'; +import 'performance_settings.dart'; + +class PerformanceScreen extends StatefulWidget { + const PerformanceScreen({super.key}); + + @override + State createState() => _PerformanceScreenState(); +} + +class _PerformanceScreenState extends State { + late final PerformanceScreenController _controller; + + @override + void initState() { + super.initState(); + _controller = PerformanceScreenController(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + return Stack( + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric(vertical: defaultPadding), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + Text('Fruits & Veggies', style: theme.textTheme.h2), + JankSettingsPopover(controller: _controller), + ], + ), + ), + const Expanded(child: FruitsList()), + ], + ), + ValueListenableBuilder( + valueListenable: _controller.rasterJankIntensity, + builder: (context, value, child) => + RasterThreadSaboteur(intensity: value.round()), + ), + ValueListenableBuilder( + valueListenable: _controller.uiJankDurationMs, + builder: (context, value, child) => + UiThreadSaboteur(jankMs: value.round()), + ), + ], + ); + } +} diff --git a/devexp_demos/devtools_companion/lib/src/screens/performance/performance_settings.dart b/devexp_demos/devtools_companion/lib/src/screens/performance/performance_settings.dart new file mode 100644 index 0000000..2c83063 --- /dev/null +++ b/devexp_demos/devtools_companion/lib/src/screens/performance/performance_settings.dart @@ -0,0 +1,98 @@ +import 'package:flutter/material.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +import '../../shared/ui/theme.dart'; +import 'performance_controller.dart'; + +class JankSettingsPopover extends StatefulWidget { + const JankSettingsPopover({super.key, required this.controller}); + + final PerformanceScreenController controller; + @override + State createState() => _JankSettingsPopoverState(); +} + +class _JankSettingsPopoverState extends State { + final popoverController = ShadPopoverController(); + + @override + void dispose() { + popoverController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ShadPopover( + controller: popoverController, + popover: (context) => JankSettings( + popoverController: popoverController, + controller: widget.controller, + ), + child: ShadButton.outline( + onPressed: popoverController.toggle, + child: const Text('Jank Controls'), + ), + ); + } +} + +class JankSettings extends StatefulWidget { + const JankSettings({ + super.key, + required this.popoverController, + required this.controller, + }); + + final ShadPopoverController popoverController; + final PerformanceScreenController controller; + + @override + State createState() => _JankSettingsState(); +} + +class _JankSettingsState extends State { + @override + Widget build(BuildContext context) { + return ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 350), + child: Padding( + padding: const EdgeInsets.only(top: defaultSpacing), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + const Text('UI Jank:'), + ValueListenableBuilder( + valueListenable: widget.controller.uiJankDurationMs, + builder: (context, value, child) { + return Slider( + value: value, + max: 50, + divisions: 10, + label: 'UI Jank Stall (${value.round()}ms)', + onChanged: (v) => + widget.controller.uiJankDurationMs.value = v, + ); + }, + ), + const Text('Raster Jank:'), + ValueListenableBuilder( + valueListenable: widget.controller.rasterJankIntensity, + builder: (context, value, child) { + return Slider( + value: value, + max: 10, + divisions: 10, + label: 'Raster Jank Intensity (${value.round()})', + onChanged: (v) => + widget.controller.rasterJankIntensity.value = v, + ); + }, + ), + ], + ), + ), + ); + } +} diff --git a/devexp_demos/devtools_companion/lib/src/screens/timeline/timeline_screen.dart b/devexp_demos/devtools_companion/lib/src/screens/timeline/timeline_screen.dart new file mode 100644 index 0000000..64bf0bc --- /dev/null +++ b/devexp_demos/devtools_companion/lib/src/screens/timeline/timeline_screen.dart @@ -0,0 +1,58 @@ +import 'dart:developer'; + +import 'package:flutter/widgets.dart'; + +import '../../shared/ui/theme.dart'; +import '../../shared/widgets/expensive_task_widget.dart'; + +class TimelineScreen extends StatelessWidget { + const TimelineScreen({super.key}); + + @override + Widget build(BuildContext context) { + return ListView( + padding: const .all(largePadding), + children: [ + ExpensiveTaskWidget( + title: 'Compute Fibonacci (32)', + task: () => _fib(32).toString(), + ), + ExpensiveTaskWidget( + title: 'Compute Fibonacci async (24)', + task: () async => (await _fibAsync(24)).toString(), + ), + // TODO: Something with `Timeline.instant`. + // TODO: Something with passing a TimelineTask to a different Isolate. + ], + ); + } +} + +Future _fibAsync(int n, {TimelineTask? parent}) async { + final task = TimelineTask(parent: parent)..start('Fib $n'); + if (n <= 1) { + // TODO: Something with `arguments` here? Do they show up in DevTools? + task.finish(); + return n; + } + await Future.delayed(const Duration(milliseconds: 16)); + final (a, b) = await ( + _fibAsync(n - 1, parent: task), + _fibAsync(n - 2, parent: task), + ).wait; + final result = a + b; + + task.finish(); + return result; +} + +int _fib(int n) { + Timeline.startSync('Fib $n'); + if (n <= 1) { + Timeline.finishSync(); + return n; + } + final result = _fib(n - 1) + _fib(n - 2); + Timeline.finishSync(); + return result; +} diff --git a/devexp_demos/devtools_companion/lib/src/shared/ui/theme.dart b/devexp_demos/devtools_companion/lib/src/shared/ui/theme.dart new file mode 100644 index 0000000..1295568 --- /dev/null +++ b/devexp_demos/devtools_companion/lib/src/shared/ui/theme.dart @@ -0,0 +1,11 @@ +const extraLargeSpacing = 32.0; +const largeSpacing = 16.0; +const defaultSpacing = 12.0; +const intermediateSpacing = 10.0; +const denseSpacing = 8.0; + +const extraLargePadding = 10.0; +const largePadding = 8.0; +const defaultPadding = 6.0; +const densePadding = 4.0; +const noPadding = 0.0; diff --git a/devexp_demos/devtools_companion/lib/src/shared/utils/utils.dart b/devexp_demos/devtools_companion/lib/src/shared/utils/utils.dart new file mode 100644 index 0000000..9fe1a3b --- /dev/null +++ b/devexp_demos/devtools_companion/lib/src/shared/utils/utils.dart @@ -0,0 +1 @@ +// Add any utility functions here. \ No newline at end of file diff --git a/devexp_demos/devtools_companion/lib/src/shared/widgets/expensive_task_widget.dart b/devexp_demos/devtools_companion/lib/src/shared/widgets/expensive_task_widget.dart new file mode 100644 index 0000000..0e48948 --- /dev/null +++ b/devexp_demos/devtools_companion/lib/src/shared/widgets/expensive_task_widget.dart @@ -0,0 +1,120 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +import '../ui/theme.dart'; + +class ExpensiveTaskWidget extends StatefulWidget { + const ExpensiveTaskWidget({ + super.key, + required this.title, + required this.task, + this.children, + }); + + final String title; + final FutureOr Function() task; + final List? children; + + @override + State createState() => _ExpensiveTaskWidgetState(); +} + +class _ExpensiveTaskWidgetState extends State { + bool _isRunning = false; + String? _result; + Duration? _executionTime; + + Future _runTask() async { + setState(() { + _isRunning = true; + _result = null; + _executionTime = null; + }); + + // Allow the UI to update to the loading state before blocking + await Future.delayed(const Duration(milliseconds: 50)); + + final stopwatch = Stopwatch()..start(); + try { + final result = await widget.task(); + stopwatch.stop(); + if (mounted) { + setState(() { + _result = result; + _executionTime = stopwatch.elapsed; + }); + } + } catch (e) { + stopwatch.stop(); + if (mounted) { + setState(() { + _result = 'Error: $e'; + _executionTime = stopwatch.elapsed; + }); + } + } finally { + if (mounted) { + setState(() { + _isRunning = false; + }); + } + } + } + + @override + Widget build(BuildContext context) { + return Card( + margin: const .all(denseSpacing), + child: Padding( + padding: const .all(largePadding), + child: Column( + crossAxisAlignment: .start, + children: [ + Text(widget.title, style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: denseSpacing), + Row( + children: [ + ShadIconButton( + icon: _isRunning + ? const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator( + color: Colors.white, + strokeWidth: 2, + ), + ) + : const Icon(Icons.play_arrow), + onPressed: _runTask, + enabled: !_isRunning, + ), + const SizedBox(width: largeSpacing), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (_executionTime != null) + Text( + 'Duration: ${_executionTime!.inMilliseconds}ms', + style: const TextStyle(fontWeight: FontWeight.bold), + ), + if (_result != null) + Text( + 'Result: $_result', + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ], + ), + if (widget.children != null) ...[Row(children: widget.children!)], + ], + ), + ), + ); + } +} diff --git a/devexp_demos/devtools_companion/lib/src/shared/widgets/todo_screen.dart b/devexp_demos/devtools_companion/lib/src/shared/widgets/todo_screen.dart new file mode 100644 index 0000000..8344db5 --- /dev/null +++ b/devexp_demos/devtools_companion/lib/src/shared/widgets/todo_screen.dart @@ -0,0 +1,71 @@ +import 'package:flutter/material.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +import '../../shared/ui/theme.dart'; + +class TodoScreen extends StatelessWidget { + const TodoScreen({super.key, required this.screenName}); + + final String screenName; + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + final isDarkMode = theme.brightness == Brightness.dark; + return Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Padding( + padding: const EdgeInsets.all(denseSpacing), + child: ShadCard( + child: Text.rich( + TextSpan( + style: theme.textTheme.p, + children: [ + TextSpan( + text: 'TODO:', + style: theme.textTheme.p.copyWith( + backgroundColor: isDarkMode + ? Colors.purple + : Colors.yellow, + fontWeight: FontWeight.bold, + ), + ), + TextSpan( + text: + ' Modify this screen to trigger useful actions for developers working on the DevTools $screenName panel.\n\n', + ), + const TextSpan( + text: 'For style consistency, please use widgets from ', + style: TextStyle(fontStyle: FontStyle.italic), + ), + const TextSpan( + text: 'package:shadcn_ui', + style: TextStyle(fontFamily: 'RobotoMono'), + ), + const TextSpan( + text: ' whenever possible.\n', + style: TextStyle(fontStyle: FontStyle.italic), + ), + ], + ), + ), + ), + ), + Padding( + padding: const EdgeInsets.only(bottom: extraLargeSpacing), + child: ShadButton.outline( + child: const Text('Click me!'), + onPressed: () { + ShadToaster.of(context).show( + ShadToast( + description: Text('Welcome to the companion tool for the DevTools $screenName Panel!'), + ), + ); + }, + ), + ), + ], + ); + } +} diff --git a/devexp_demos/devtools_companion/linux/.gitignore b/devexp_demos/devtools_companion/linux/.gitignore new file mode 100644 index 0000000..d3896c9 --- /dev/null +++ b/devexp_demos/devtools_companion/linux/.gitignore @@ -0,0 +1 @@ +flutter/ephemeral diff --git a/devexp_demos/devtools_companion/linux/CMakeLists.txt b/devexp_demos/devtools_companion/linux/CMakeLists.txt new file mode 100644 index 0000000..da2dca3 --- /dev/null +++ b/devexp_demos/devtools_companion/linux/CMakeLists.txt @@ -0,0 +1,128 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.13) +project(runner LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "devtools_companion") +# The unique GTK application identifier for this application. See: +# https://wiki.gnome.org/HowDoI/ChooseApplicationID +set(APPLICATION_ID "com.example.devtools_companion") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(SET CMP0063 NEW) + +# Load bundled libraries from the lib/ directory relative to the binary. +set(CMAKE_INSTALL_RPATH "$ORIGIN/lib") + +# Root filesystem for cross-building. +if(FLUTTER_TARGET_PLATFORM_SYSROOT) + set(CMAKE_SYSROOT ${FLUTTER_TARGET_PLATFORM_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) + set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY) +endif() + +# Define build configuration options. +if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") +endif() + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_14) + target_compile_options(${TARGET} PRIVATE -Wall -Werror) + target_compile_options(${TARGET} PRIVATE "$<$>:-O3>") + target_compile_definitions(${TARGET} PRIVATE "$<$>:NDEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) + +# Only the install-generated bundle's copy of the executable will launch +# correctly, since the resources must in the right relative locations. To avoid +# people trying to run the unbundled copy, put it in a subdirectory instead of +# the default top-level location. +set_target_properties(${BINARY_NAME} + PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run" +) + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# By default, "installing" just makes a relocatable bundle in the build +# directory. +set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle") +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +# Start with a clean build bundle directory every time. +install(CODE " + file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\") + " COMPONENT Runtime) + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES}) + install(FILES "${bundled_library}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endforeach(bundled_library) + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/linux/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +if(NOT CMAKE_BUILD_TYPE MATCHES "Debug") + install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() diff --git a/devexp_demos/devtools_companion/linux/flutter/CMakeLists.txt b/devexp_demos/devtools_companion/linux/flutter/CMakeLists.txt new file mode 100644 index 0000000..d5bd016 --- /dev/null +++ b/devexp_demos/devtools_companion/linux/flutter/CMakeLists.txt @@ -0,0 +1,88 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.10) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. + +# Serves the same purpose as list(TRANSFORM ... PREPEND ...), +# which isn't available in 3.10. +function(list_prepend LIST_NAME PREFIX) + set(NEW_LIST "") + foreach(element ${${LIST_NAME}}) + list(APPEND NEW_LIST "${PREFIX}${element}") + endforeach(element) + set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE) +endfunction() + +# === Flutter Library === +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) +pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0) +pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0) + +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/lib/libapp.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "fl_basic_message_channel.h" + "fl_binary_codec.h" + "fl_binary_messenger.h" + "fl_dart_project.h" + "fl_engine.h" + "fl_json_message_codec.h" + "fl_json_method_codec.h" + "fl_message_codec.h" + "fl_method_call.h" + "fl_method_channel.h" + "fl_method_codec.h" + "fl_method_response.h" + "fl_plugin_registrar.h" + "fl_plugin_registry.h" + "fl_standard_message_codec.h" + "fl_standard_method_codec.h" + "fl_string_codec.h" + "fl_value.h" + "fl_view.h" + "flutter_linux.h" +) +list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}") +target_link_libraries(flutter INTERFACE + PkgConfig::GTK + PkgConfig::GLIB + PkgConfig::GIO +) +add_dependencies(flutter flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CMAKE_CURRENT_BINARY_DIR}/_phony_ + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh" + ${FLUTTER_TARGET_PLATFORM} ${CMAKE_BUILD_TYPE} + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} +) diff --git a/devexp_demos/devtools_companion/linux/flutter/generated_plugin_registrant.cc b/devexp_demos/devtools_companion/linux/flutter/generated_plugin_registrant.cc new file mode 100644 index 0000000..e71a16d --- /dev/null +++ b/devexp_demos/devtools_companion/linux/flutter/generated_plugin_registrant.cc @@ -0,0 +1,11 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + + +void fl_register_plugins(FlPluginRegistry* registry) { +} diff --git a/devexp_demos/devtools_companion/linux/flutter/generated_plugin_registrant.h b/devexp_demos/devtools_companion/linux/flutter/generated_plugin_registrant.h new file mode 100644 index 0000000..e0f0a47 --- /dev/null +++ b/devexp_demos/devtools_companion/linux/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void fl_register_plugins(FlPluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/devexp_demos/devtools_companion/linux/flutter/generated_plugins.cmake b/devexp_demos/devtools_companion/linux/flutter/generated_plugins.cmake new file mode 100644 index 0000000..2e1de87 --- /dev/null +++ b/devexp_demos/devtools_companion/linux/flutter/generated_plugins.cmake @@ -0,0 +1,23 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/devexp_demos/devtools_companion/linux/runner/CMakeLists.txt b/devexp_demos/devtools_companion/linux/runner/CMakeLists.txt new file mode 100644 index 0000000..e97dabc --- /dev/null +++ b/devexp_demos/devtools_companion/linux/runner/CMakeLists.txt @@ -0,0 +1,26 @@ +cmake_minimum_required(VERSION 3.13) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} + "main.cc" + "my_application.cc" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the application ID. +add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}") + +# Add dependency libraries. Add any application-specific dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter) +target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK) + +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") diff --git a/devexp_demos/devtools_companion/linux/runner/main.cc b/devexp_demos/devtools_companion/linux/runner/main.cc new file mode 100644 index 0000000..e7c5c54 --- /dev/null +++ b/devexp_demos/devtools_companion/linux/runner/main.cc @@ -0,0 +1,6 @@ +#include "my_application.h" + +int main(int argc, char** argv) { + g_autoptr(MyApplication) app = my_application_new(); + return g_application_run(G_APPLICATION(app), argc, argv); +} diff --git a/devexp_demos/devtools_companion/linux/runner/my_application.cc b/devexp_demos/devtools_companion/linux/runner/my_application.cc new file mode 100644 index 0000000..8dc54d5 --- /dev/null +++ b/devexp_demos/devtools_companion/linux/runner/my_application.cc @@ -0,0 +1,148 @@ +#include "my_application.h" + +#include +#ifdef GDK_WINDOWING_X11 +#include +#endif + +#include "flutter/generated_plugin_registrant.h" + +struct _MyApplication { + GtkApplication parent_instance; + char** dart_entrypoint_arguments; +}; + +G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) + +// Called when first Flutter frame received. +static void first_frame_cb(MyApplication* self, FlView* view) { + gtk_widget_show(gtk_widget_get_toplevel(GTK_WIDGET(view))); +} + +// Implements GApplication::activate. +static void my_application_activate(GApplication* application) { + MyApplication* self = MY_APPLICATION(application); + GtkWindow* window = + GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); + + // Use a header bar when running in GNOME as this is the common style used + // by applications and is the setup most users will be using (e.g. Ubuntu + // desktop). + // If running on X and not using GNOME then just use a traditional title bar + // in case the window manager does more exotic layout, e.g. tiling. + // If running on Wayland assume the header bar will work (may need changing + // if future cases occur). + gboolean use_header_bar = TRUE; +#ifdef GDK_WINDOWING_X11 + GdkScreen* screen = gtk_window_get_screen(window); + if (GDK_IS_X11_SCREEN(screen)) { + const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen); + if (g_strcmp0(wm_name, "GNOME Shell") != 0) { + use_header_bar = FALSE; + } + } +#endif + if (use_header_bar) { + GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); + gtk_widget_show(GTK_WIDGET(header_bar)); + gtk_header_bar_set_title(header_bar, "devtools_companion"); + gtk_header_bar_set_show_close_button(header_bar, TRUE); + gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); + } else { + gtk_window_set_title(window, "devtools_companion"); + } + + gtk_window_set_default_size(window, 1280, 720); + + g_autoptr(FlDartProject) project = fl_dart_project_new(); + fl_dart_project_set_dart_entrypoint_arguments( + project, self->dart_entrypoint_arguments); + + FlView* view = fl_view_new(project); + GdkRGBA background_color; + // Background defaults to black, override it here if necessary, e.g. #00000000 + // for transparent. + gdk_rgba_parse(&background_color, "#000000"); + fl_view_set_background_color(view, &background_color); + gtk_widget_show(GTK_WIDGET(view)); + gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view)); + + // Show the window when Flutter renders. + // Requires the view to be realized so we can start rendering. + g_signal_connect_swapped(view, "first-frame", G_CALLBACK(first_frame_cb), + self); + gtk_widget_realize(GTK_WIDGET(view)); + + fl_register_plugins(FL_PLUGIN_REGISTRY(view)); + + gtk_widget_grab_focus(GTK_WIDGET(view)); +} + +// Implements GApplication::local_command_line. +static gboolean my_application_local_command_line(GApplication* application, + gchar*** arguments, + int* exit_status) { + MyApplication* self = MY_APPLICATION(application); + // Strip out the first argument as it is the binary name. + self->dart_entrypoint_arguments = g_strdupv(*arguments + 1); + + g_autoptr(GError) error = nullptr; + if (!g_application_register(application, nullptr, &error)) { + g_warning("Failed to register: %s", error->message); + *exit_status = 1; + return TRUE; + } + + g_application_activate(application); + *exit_status = 0; + + return TRUE; +} + +// Implements GApplication::startup. +static void my_application_startup(GApplication* application) { + // MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application startup. + + G_APPLICATION_CLASS(my_application_parent_class)->startup(application); +} + +// Implements GApplication::shutdown. +static void my_application_shutdown(GApplication* application) { + // MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application shutdown. + + G_APPLICATION_CLASS(my_application_parent_class)->shutdown(application); +} + +// Implements GObject::dispose. +static void my_application_dispose(GObject* object) { + MyApplication* self = MY_APPLICATION(object); + g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev); + G_OBJECT_CLASS(my_application_parent_class)->dispose(object); +} + +static void my_application_class_init(MyApplicationClass* klass) { + G_APPLICATION_CLASS(klass)->activate = my_application_activate; + G_APPLICATION_CLASS(klass)->local_command_line = + my_application_local_command_line; + G_APPLICATION_CLASS(klass)->startup = my_application_startup; + G_APPLICATION_CLASS(klass)->shutdown = my_application_shutdown; + G_OBJECT_CLASS(klass)->dispose = my_application_dispose; +} + +static void my_application_init(MyApplication* self) {} + +MyApplication* my_application_new() { + // Set the program name to the application ID, which helps various systems + // like GTK and desktop environments map this running application to its + // corresponding .desktop file. This ensures better integration by allowing + // the application to be recognized beyond its binary name. + g_set_prgname(APPLICATION_ID); + + return MY_APPLICATION(g_object_new(my_application_get_type(), + "application-id", APPLICATION_ID, "flags", + G_APPLICATION_NON_UNIQUE, nullptr)); +} diff --git a/devexp_demos/devtools_companion/linux/runner/my_application.h b/devexp_demos/devtools_companion/linux/runner/my_application.h new file mode 100644 index 0000000..db16367 --- /dev/null +++ b/devexp_demos/devtools_companion/linux/runner/my_application.h @@ -0,0 +1,21 @@ +#ifndef FLUTTER_MY_APPLICATION_H_ +#define FLUTTER_MY_APPLICATION_H_ + +#include + +G_DECLARE_FINAL_TYPE(MyApplication, + my_application, + MY, + APPLICATION, + GtkApplication) + +/** + * my_application_new: + * + * Creates a new Flutter-based application. + * + * Returns: a new #MyApplication. + */ +MyApplication* my_application_new(); + +#endif // FLUTTER_MY_APPLICATION_H_ diff --git a/devexp_demos/devtools_companion/macos/.gitignore b/devexp_demos/devtools_companion/macos/.gitignore new file mode 100644 index 0000000..746adbb --- /dev/null +++ b/devexp_demos/devtools_companion/macos/.gitignore @@ -0,0 +1,7 @@ +# Flutter-related +**/Flutter/ephemeral/ +**/Pods/ + +# Xcode-related +**/dgph +**/xcuserdata/ diff --git a/devexp_demos/devtools_companion/macos/Flutter/Flutter-Debug.xcconfig b/devexp_demos/devtools_companion/macos/Flutter/Flutter-Debug.xcconfig new file mode 100644 index 0000000..4b81f9b --- /dev/null +++ b/devexp_demos/devtools_companion/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/devexp_demos/devtools_companion/macos/Flutter/Flutter-Release.xcconfig b/devexp_demos/devtools_companion/macos/Flutter/Flutter-Release.xcconfig new file mode 100644 index 0000000..5caa9d1 --- /dev/null +++ b/devexp_demos/devtools_companion/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/devexp_demos/devtools_companion/macos/Flutter/GeneratedPluginRegistrant.swift b/devexp_demos/devtools_companion/macos/Flutter/GeneratedPluginRegistrant.swift new file mode 100644 index 0000000..e777c67 --- /dev/null +++ b/devexp_demos/devtools_companion/macos/Flutter/GeneratedPluginRegistrant.swift @@ -0,0 +1,12 @@ +// +// Generated file. Do not edit. +// + +import FlutterMacOS +import Foundation + +import path_provider_foundation + +func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) +} diff --git a/devexp_demos/devtools_companion/macos/Podfile b/devexp_demos/devtools_companion/macos/Podfile new file mode 100644 index 0000000..ff5ddb3 --- /dev/null +++ b/devexp_demos/devtools_companion/macos/Podfile @@ -0,0 +1,42 @@ +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! + + 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/devexp_demos/devtools_companion/macos/Runner.xcodeproj/project.pbxproj b/devexp_demos/devtools_companion/macos/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..4d23f6f --- /dev/null +++ b/devexp_demos/devtools_companion/macos/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,807 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXAggregateTarget section */ + 33CC111A2044C6BA0003C045 /* Flutter Assemble */ = { + isa = PBXAggregateTarget; + buildConfigurationList = 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */; + buildPhases = ( + 33CC111E2044C6BF0003C045 /* ShellScript */, + ); + dependencies = ( + ); + name = "Flutter Assemble"; + productName = FLX; + }; +/* End PBXAggregateTarget section */ + +/* Begin PBXBuildFile section */ + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C80D7294CF71000263BE5 /* RunnerTests.swift */; }; + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; }; + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; }; + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; + 6F413FAD4AA73560467A1A2D /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9CF4E20C67BF90C5F53E614B /* Pods_Runner.framework */; }; + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; + DFB82DFA499226169AE0FD1D /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 30E6D50257FB0BE25A1B500F /* Pods_RunnerTests.framework */; }; +/* 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 */ + 0025F80803E006A46F7D5A60 /* 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 = ""; }; + 1154510954E456E256FCC9AC /* 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 = ""; }; + 30E6D50257FB0BE25A1B500F /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 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 /* devtools_companion.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = devtools_companion.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 = ""; }; + 7086B220ED5F2794E269BB41 /* 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 = ""; }; + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + 916D1A9B38CB0017899912E3 /* 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 = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; + 9CF4E20C67BF90C5F53E614B /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + D86AE9CE263C4829CDC29CED /* 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 = ""; }; + D9189555FB6531528618687C /* 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 = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 331C80D2294CF70F00263BE5 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + DFB82DFA499226169AE0FD1D /* Pods_RunnerTests.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EA2044A3C60003C045 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, + 6F413FAD4AA73560467A1A2D /* Pods_Runner.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 2DC258FF76B4803FB11B58EB /* Pods */ = { + isa = PBXGroup; + children = ( + D86AE9CE263C4829CDC29CED /* Pods-Runner.debug.xcconfig */, + 7086B220ED5F2794E269BB41 /* Pods-Runner.release.xcconfig */, + 0025F80803E006A46F7D5A60 /* Pods-Runner.profile.xcconfig */, + 916D1A9B38CB0017899912E3 /* Pods-RunnerTests.debug.xcconfig */, + D9189555FB6531528618687C /* Pods-RunnerTests.release.xcconfig */, + 1154510954E456E256FCC9AC /* Pods-RunnerTests.profile.xcconfig */, + ); + name = Pods; + path = Pods; + sourceTree = ""; + }; + 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 */, + 2DC258FF76B4803FB11B58EB /* Pods */, + ); + sourceTree = ""; + }; + 33CC10EE2044A3C60003C045 /* Products */ = { + isa = PBXGroup; + children = ( + 33CC10ED2044A3C60003C045 /* devtools_companion.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 = ( + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */, + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */, + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */, + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */, + ); + path = Flutter; + sourceTree = ""; + }; + 33FAB671232836740065AC1E /* Runner */ = { + isa = PBXGroup; + children = ( + 33CC10F02044A3C60003C045 /* AppDelegate.swift */, + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */, + 33E51913231747F40026EE4D /* DebugProfile.entitlements */, + 33E51914231749380026EE4D /* Release.entitlements */, + 33CC11242044D66E0003C045 /* Resources */, + 33BA886A226E78AF003329D5 /* Configs */, + ); + path = Runner; + sourceTree = ""; + }; + D73912EC22F37F3D000D13A0 /* Frameworks */ = { + isa = PBXGroup; + children = ( + 9CF4E20C67BF90C5F53E614B /* Pods_Runner.framework */, + 30E6D50257FB0BE25A1B500F /* 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 = ( + 6583B404D2E556EFF5EA0925 /* [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 = ( + 32737C9057EE657B5175BDE4 /* [CP] Check Pods Manifest.lock */, + 33CC10E92044A3C60003C045 /* Sources */, + 33CC10EA2044A3C60003C045 /* Frameworks */, + 33CC10EB2044A3C60003C045 /* Resources */, + 33CC110E2044A8840003C045 /* Bundle Framework */, + 3399D490228B24CF009A79C7 /* ShellScript */, + ); + buildRules = ( + ); + dependencies = ( + 33CC11202044C79F0003C045 /* PBXTargetDependency */, + ); + name = Runner; + packageProductDependencies = ( + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, + ); + productName = Runner; + productReference = 33CC10ED2044A3C60003C045 /* devtools_companion.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; + packageReferences = ( + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */, + ); + productRefGroup = 33CC10EE2044A3C60003C045 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 33CC10EC2044A3C60003C045 /* Runner */, + 331C80D4294CF70F00263BE5 /* RunnerTests */, + 33CC111A2044C6BA0003C045 /* Flutter Assemble */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C80D3294CF70F00263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EB2044A3C60003C045 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */, + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 32737C9057EE657B5175BDE4 /* [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; + }; + 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"; + }; + 6583B404D2E556EFF5EA0925 /* [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; + }; +/* 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 = 916D1A9B38CB0017899912E3 /* 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.devtoolsCompanion.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/devtools_companion.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/devtools_companion"; + }; + name = Debug; + }; + 331C80DC294CF71000263BE5 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = D9189555FB6531528618687C /* 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.devtoolsCompanion.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/devtools_companion.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/devtools_companion"; + }; + name = Release; + }; + 331C80DD294CF71000263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 1154510954E456E256FCC9AC /* 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.devtoolsCompanion.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/devtools_companion.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/devtools_companion"; + }; + 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 */ + +/* Begin XCLocalSwiftPackageReference section */ + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = { + isa = XCSwiftPackageProductDependency; + productName = FlutterGeneratedPluginSwiftPackage; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 33CC10E52044A3C60003C045 /* Project object */; +} diff --git a/devexp_demos/devtools_companion/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/devexp_demos/devtools_companion/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/devexp_demos/devtools_companion/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/devexp_demos/devtools_companion/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/devexp_demos/devtools_companion/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..a7fc98c --- /dev/null +++ b/devexp_demos/devtools_companion/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,117 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/devexp_demos/devtools_companion/macos/Runner.xcworkspace/contents.xcworkspacedata b/devexp_demos/devtools_companion/macos/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..21a3cc1 --- /dev/null +++ b/devexp_demos/devtools_companion/macos/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/devexp_demos/devtools_companion/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/devexp_demos/devtools_companion/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/devexp_demos/devtools_companion/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/devexp_demos/devtools_companion/macos/Runner/AppDelegate.swift b/devexp_demos/devtools_companion/macos/Runner/AppDelegate.swift new file mode 100644 index 0000000..b3c1761 --- /dev/null +++ b/devexp_demos/devtools_companion/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/devexp_demos/devtools_companion/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/devexp_demos/devtools_companion/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..a2ec33f --- /dev/null +++ b/devexp_demos/devtools_companion/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/devexp_demos/devtools_companion/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/devexp_demos/devtools_companion/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png new file mode 100644 index 0000000..82b6f9d Binary files /dev/null and b/devexp_demos/devtools_companion/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png differ diff --git a/devexp_demos/devtools_companion/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/devexp_demos/devtools_companion/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png new file mode 100644 index 0000000..13b35eb Binary files /dev/null and b/devexp_demos/devtools_companion/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png differ diff --git a/devexp_demos/devtools_companion/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png b/devexp_demos/devtools_companion/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png new file mode 100644 index 0000000..0a3f5fa Binary files /dev/null and b/devexp_demos/devtools_companion/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png differ diff --git a/devexp_demos/devtools_companion/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png b/devexp_demos/devtools_companion/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png new file mode 100644 index 0000000..bdb5722 Binary files /dev/null and b/devexp_demos/devtools_companion/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png differ diff --git a/devexp_demos/devtools_companion/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png b/devexp_demos/devtools_companion/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png new file mode 100644 index 0000000..f083318 Binary files /dev/null and b/devexp_demos/devtools_companion/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png differ diff --git a/devexp_demos/devtools_companion/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png b/devexp_demos/devtools_companion/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png new file mode 100644 index 0000000..326c0e7 Binary files /dev/null and b/devexp_demos/devtools_companion/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png differ diff --git a/devexp_demos/devtools_companion/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png b/devexp_demos/devtools_companion/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png new file mode 100644 index 0000000..2f1632c Binary files /dev/null and b/devexp_demos/devtools_companion/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png differ diff --git a/devexp_demos/devtools_companion/macos/Runner/Base.lproj/MainMenu.xib b/devexp_demos/devtools_companion/macos/Runner/Base.lproj/MainMenu.xib new file mode 100644 index 0000000..80e867a --- /dev/null +++ b/devexp_demos/devtools_companion/macos/Runner/Base.lproj/MainMenu.xib @@ -0,0 +1,343 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/devexp_demos/devtools_companion/macos/Runner/Configs/AppInfo.xcconfig b/devexp_demos/devtools_companion/macos/Runner/Configs/AppInfo.xcconfig new file mode 100644 index 0000000..b65f9fc --- /dev/null +++ b/devexp_demos/devtools_companion/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 = devtools_companion + +// The application's bundle identifier +PRODUCT_BUNDLE_IDENTIFIER = com.example.devtoolsCompanion + +// The copyright displayed in application information +PRODUCT_COPYRIGHT = Copyright © 2025 com.example. All rights reserved. diff --git a/devexp_demos/devtools_companion/macos/Runner/Configs/Debug.xcconfig b/devexp_demos/devtools_companion/macos/Runner/Configs/Debug.xcconfig new file mode 100644 index 0000000..36b0fd9 --- /dev/null +++ b/devexp_demos/devtools_companion/macos/Runner/Configs/Debug.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Debug.xcconfig" +#include "Warnings.xcconfig" diff --git a/devexp_demos/devtools_companion/macos/Runner/Configs/Release.xcconfig b/devexp_demos/devtools_companion/macos/Runner/Configs/Release.xcconfig new file mode 100644 index 0000000..dff4f49 --- /dev/null +++ b/devexp_demos/devtools_companion/macos/Runner/Configs/Release.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Release.xcconfig" +#include "Warnings.xcconfig" diff --git a/devexp_demos/devtools_companion/macos/Runner/Configs/Warnings.xcconfig b/devexp_demos/devtools_companion/macos/Runner/Configs/Warnings.xcconfig new file mode 100644 index 0000000..42bcbf4 --- /dev/null +++ b/devexp_demos/devtools_companion/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/devexp_demos/devtools_companion/macos/Runner/DebugProfile.entitlements b/devexp_demos/devtools_companion/macos/Runner/DebugProfile.entitlements new file mode 100644 index 0000000..dddb8a3 --- /dev/null +++ b/devexp_demos/devtools_companion/macos/Runner/DebugProfile.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.cs.allow-jit + + com.apple.security.network.server + + + diff --git a/devexp_demos/devtools_companion/macos/Runner/Info.plist b/devexp_demos/devtools_companion/macos/Runner/Info.plist new file mode 100644 index 0000000..4789daa --- /dev/null +++ b/devexp_demos/devtools_companion/macos/Runner/Info.plist @@ -0,0 +1,32 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIconFile + + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + NSHumanReadableCopyright + $(PRODUCT_COPYRIGHT) + NSMainNibFile + MainMenu + NSPrincipalClass + NSApplication + + diff --git a/devexp_demos/devtools_companion/macos/Runner/MainFlutterWindow.swift b/devexp_demos/devtools_companion/macos/Runner/MainFlutterWindow.swift new file mode 100644 index 0000000..3cc05eb --- /dev/null +++ b/devexp_demos/devtools_companion/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/devexp_demos/devtools_companion/macos/Runner/Release.entitlements b/devexp_demos/devtools_companion/macos/Runner/Release.entitlements new file mode 100644 index 0000000..852fa1a --- /dev/null +++ b/devexp_demos/devtools_companion/macos/Runner/Release.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.security.app-sandbox + + + diff --git a/devexp_demos/devtools_companion/macos/RunnerTests/RunnerTests.swift b/devexp_demos/devtools_companion/macos/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..61f3bd1 --- /dev/null +++ b/devexp_demos/devtools_companion/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/devexp_demos/devtools_companion/pubspec.yaml b/devexp_demos/devtools_companion/pubspec.yaml new file mode 100644 index 0000000..e92a7e9 --- /dev/null +++ b/devexp_demos/devtools_companion/pubspec.yaml @@ -0,0 +1,68 @@ +name: devtools_companion +description: "Companion app for developers working on Flutter/Dart Devtools" +publish_to: 'none' + +version: 1.0.0+1 + +environment: + sdk: ^3.10.0 + +dependencies: + flutter: + sdk: flutter + + # The following adds the Cupertino Icons font to your application. + # Use with the CupertinoIcons class for iOS style icons. + cupertino_icons: ^1.0.8 + provider: ^6.1.5+1 + shadcn_ui: ^0.39.6 + flutter_markdown: ^0.7.7+1 + dio: ^5.9.0 + flutter_animate: ^4.5.2 + http: ^1.6.0 + intl: ^0.20.2 + flutter_driver: + sdk: flutter + logging: ^1.3.0 + +dev_dependencies: + flutter_test: + sdk: flutter + + flutter_lints: ^6.0.0 + +flutter: + uses-material-design: true + + assets: + - README.md + - assets/icons/ + - assets/images/ + + fonts: + - family: Roboto + fonts: + - asset: assets/fonts/Roboto/Roboto-Thin.ttf + weight: 100 + - asset: assets/fonts/Roboto/Roboto-Light.ttf + weight: 300 + - asset: assets/fonts/Roboto/Roboto-Regular.ttf + weight: 400 + - asset: assets/fonts/Roboto/Roboto-Medium.ttf + weight: 500 + - asset: assets/fonts/Roboto/Roboto-Bold.ttf + weight: 700 + - asset: assets/fonts/Roboto/Roboto-Black.ttf + weight: 900 + - family: RobotoMono + fonts: + - asset: assets/fonts/Roboto_Mono/RobotoMono-Thin.ttf + weight: 100 + - asset: assets/fonts/Roboto_Mono/RobotoMono-Light.ttf + weight: 300 + - asset: assets/fonts/Roboto_Mono/RobotoMono-Regular.ttf + weight: 400 + - asset: assets/fonts/Roboto_Mono/RobotoMono-Medium.ttf + weight: 500 + - asset: assets/fonts/Roboto_Mono/RobotoMono-Bold.ttf + weight: 700 diff --git a/devexp_demos/devtools_companion/test/widget_test.dart b/devexp_demos/devtools_companion/test/widget_test.dart new file mode 100644 index 0000000..f970894 --- /dev/null +++ b/devexp_demos/devtools_companion/test/widget_test.dart @@ -0,0 +1,9 @@ +import 'package:devtools_companion/main.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Basic test', (WidgetTester tester) async { + await tester.pumpWidget(const DevToolsCompanionApp()); + expect(find.text('DevTools'), findsOneWidget); + }); +} diff --git a/devexp_demos/devtools_companion/web/favicon.png b/devexp_demos/devtools_companion/web/favicon.png new file mode 100644 index 0000000..8aaa46a Binary files /dev/null and b/devexp_demos/devtools_companion/web/favicon.png differ diff --git a/devexp_demos/devtools_companion/web/icons/Icon-192.png b/devexp_demos/devtools_companion/web/icons/Icon-192.png new file mode 100644 index 0000000..b749bfe Binary files /dev/null and b/devexp_demos/devtools_companion/web/icons/Icon-192.png differ diff --git a/devexp_demos/devtools_companion/web/icons/Icon-512.png b/devexp_demos/devtools_companion/web/icons/Icon-512.png new file mode 100644 index 0000000..88cfd48 Binary files /dev/null and b/devexp_demos/devtools_companion/web/icons/Icon-512.png differ diff --git a/devexp_demos/devtools_companion/web/icons/Icon-maskable-192.png b/devexp_demos/devtools_companion/web/icons/Icon-maskable-192.png new file mode 100644 index 0000000..eb9b4d7 Binary files /dev/null and b/devexp_demos/devtools_companion/web/icons/Icon-maskable-192.png differ diff --git a/devexp_demos/devtools_companion/web/icons/Icon-maskable-512.png b/devexp_demos/devtools_companion/web/icons/Icon-maskable-512.png new file mode 100644 index 0000000..d69c566 Binary files /dev/null and b/devexp_demos/devtools_companion/web/icons/Icon-maskable-512.png differ diff --git a/devexp_demos/devtools_companion/web/index.html b/devexp_demos/devtools_companion/web/index.html new file mode 100644 index 0000000..be0fc21 --- /dev/null +++ b/devexp_demos/devtools_companion/web/index.html @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + devtools_companion + + + + + + diff --git a/devexp_demos/devtools_companion/web/manifest.json b/devexp_demos/devtools_companion/web/manifest.json new file mode 100644 index 0000000..72d0ec6 --- /dev/null +++ b/devexp_demos/devtools_companion/web/manifest.json @@ -0,0 +1,35 @@ +{ + "name": "devtools_companion", + "short_name": "devtools_companion", + "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/devexp_demos/devtools_companion/windows/.gitignore b/devexp_demos/devtools_companion/windows/.gitignore new file mode 100644 index 0000000..d492d0d --- /dev/null +++ b/devexp_demos/devtools_companion/windows/.gitignore @@ -0,0 +1,17 @@ +flutter/ephemeral/ + +# Visual Studio user-specific files. +*.suo +*.user +*.userosscache +*.sln.docstates + +# Visual Studio build-related files. +x64/ +x86/ + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ diff --git a/devexp_demos/devtools_companion/windows/CMakeLists.txt b/devexp_demos/devtools_companion/windows/CMakeLists.txt new file mode 100644 index 0000000..1cb7a4a --- /dev/null +++ b/devexp_demos/devtools_companion/windows/CMakeLists.txt @@ -0,0 +1,108 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.14) +project(devtools_companion LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "devtools_companion") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(VERSION 3.14...3.25) + +# Define build configuration option. +get_property(IS_MULTICONFIG GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG) +if(IS_MULTICONFIG) + set(CMAKE_CONFIGURATION_TYPES "Debug;Profile;Release" + CACHE STRING "" FORCE) +else() + if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") + endif() +endif() +# Define settings for the Profile build mode. +set(CMAKE_EXE_LINKER_FLAGS_PROFILE "${CMAKE_EXE_LINKER_FLAGS_RELEASE}") +set(CMAKE_SHARED_LINKER_FLAGS_PROFILE "${CMAKE_SHARED_LINKER_FLAGS_RELEASE}") +set(CMAKE_C_FLAGS_PROFILE "${CMAKE_C_FLAGS_RELEASE}") +set(CMAKE_CXX_FLAGS_PROFILE "${CMAKE_CXX_FLAGS_RELEASE}") + +# Use Unicode for all projects. +add_definitions(-DUNICODE -D_UNICODE) + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_17) + target_compile_options(${TARGET} PRIVATE /W4 /WX /wd"4100") + target_compile_options(${TARGET} PRIVATE /EHsc) + target_compile_definitions(${TARGET} PRIVATE "_HAS_EXCEPTIONS=0") + target_compile_definitions(${TARGET} PRIVATE "$<$:_DEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# Support files are copied into place next to the executable, so that it can +# run in place. This is done instead of making a separate bundle (as on Linux) +# so that building and running from within Visual Studio will work. +set(BUILD_BUNDLE_DIR "$") +# Make the "install" step default, as it's required to run. +set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD 1) +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +if(PLUGIN_BUNDLED_LIBRARIES) + install(FILES "${PLUGIN_BUNDLED_LIBRARIES}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/windows/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + CONFIGURATIONS Profile;Release + COMPONENT Runtime) diff --git a/devexp_demos/devtools_companion/windows/flutter/CMakeLists.txt b/devexp_demos/devtools_companion/windows/flutter/CMakeLists.txt new file mode 100644 index 0000000..903f489 --- /dev/null +++ b/devexp_demos/devtools_companion/windows/flutter/CMakeLists.txt @@ -0,0 +1,109 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.14) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. +set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper") + +# Set fallback configurations for older versions of the flutter tool. +if (NOT DEFINED FLUTTER_TARGET_PLATFORM) + set(FLUTTER_TARGET_PLATFORM "windows-x64") +endif() + +# === Flutter Library === +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/windows/app.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "flutter_export.h" + "flutter_windows.h" + "flutter_messenger.h" + "flutter_plugin_registrar.h" + "flutter_texture_registrar.h" +) +list(TRANSFORM FLUTTER_LIBRARY_HEADERS PREPEND "${EPHEMERAL_DIR}/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}.lib") +add_dependencies(flutter flutter_assemble) + +# === Wrapper === +list(APPEND CPP_WRAPPER_SOURCES_CORE + "core_implementations.cc" + "standard_codec.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_CORE PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_PLUGIN + "plugin_registrar.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_PLUGIN PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_APP + "flutter_engine.cc" + "flutter_view_controller.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_APP PREPEND "${WRAPPER_ROOT}/") + +# Wrapper sources needed for a plugin. +add_library(flutter_wrapper_plugin STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} +) +apply_standard_settings(flutter_wrapper_plugin) +set_target_properties(flutter_wrapper_plugin PROPERTIES + POSITION_INDEPENDENT_CODE ON) +set_target_properties(flutter_wrapper_plugin PROPERTIES + CXX_VISIBILITY_PRESET hidden) +target_link_libraries(flutter_wrapper_plugin PUBLIC flutter) +target_include_directories(flutter_wrapper_plugin PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_plugin flutter_assemble) + +# Wrapper sources needed for the runner. +add_library(flutter_wrapper_app STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_APP} +) +apply_standard_settings(flutter_wrapper_app) +target_link_libraries(flutter_wrapper_app PUBLIC flutter) +target_include_directories(flutter_wrapper_app PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_app flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +set(PHONY_OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/_phony_") +set_source_files_properties("${PHONY_OUTPUT}" PROPERTIES SYMBOLIC TRUE) +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} + ${PHONY_OUTPUT} + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat" + ${FLUTTER_TARGET_PLATFORM} $ + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} +) diff --git a/devexp_demos/devtools_companion/windows/flutter/generated_plugin_registrant.cc b/devexp_demos/devtools_companion/windows/flutter/generated_plugin_registrant.cc new file mode 100644 index 0000000..8b6d468 --- /dev/null +++ b/devexp_demos/devtools_companion/windows/flutter/generated_plugin_registrant.cc @@ -0,0 +1,11 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + + +void RegisterPlugins(flutter::PluginRegistry* registry) { +} diff --git a/devexp_demos/devtools_companion/windows/flutter/generated_plugin_registrant.h b/devexp_demos/devtools_companion/windows/flutter/generated_plugin_registrant.h new file mode 100644 index 0000000..dc139d8 --- /dev/null +++ b/devexp_demos/devtools_companion/windows/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void RegisterPlugins(flutter::PluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/devexp_demos/devtools_companion/windows/flutter/generated_plugins.cmake b/devexp_demos/devtools_companion/windows/flutter/generated_plugins.cmake new file mode 100644 index 0000000..b93c4c3 --- /dev/null +++ b/devexp_demos/devtools_companion/windows/flutter/generated_plugins.cmake @@ -0,0 +1,23 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/windows plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/windows plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/devexp_demos/devtools_companion/windows/runner/CMakeLists.txt b/devexp_demos/devtools_companion/windows/runner/CMakeLists.txt new file mode 100644 index 0000000..394917c --- /dev/null +++ b/devexp_demos/devtools_companion/windows/runner/CMakeLists.txt @@ -0,0 +1,40 @@ +cmake_minimum_required(VERSION 3.14) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} WIN32 + "flutter_window.cpp" + "main.cpp" + "utils.cpp" + "win32_window.cpp" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" + "Runner.rc" + "runner.exe.manifest" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the build version. +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION=\"${FLUTTER_VERSION}\"") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MAJOR=${FLUTTER_VERSION_MAJOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MINOR=${FLUTTER_VERSION_MINOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_PATCH=${FLUTTER_VERSION_PATCH}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_BUILD=${FLUTTER_VERSION_BUILD}") + +# Disable Windows macros that collide with C++ standard library functions. +target_compile_definitions(${BINARY_NAME} PRIVATE "NOMINMAX") + +# Add dependency libraries and include directories. Add any application-specific +# dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app) +target_link_libraries(${BINARY_NAME} PRIVATE "dwmapi.lib") +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) diff --git a/devexp_demos/devtools_companion/windows/runner/Runner.rc b/devexp_demos/devtools_companion/windows/runner/Runner.rc new file mode 100644 index 0000000..a992832 --- /dev/null +++ b/devexp_demos/devtools_companion/windows/runner/Runner.rc @@ -0,0 +1,121 @@ +// Microsoft Visual C++ generated resource script. +// +#pragma code_page(65001) +#include "resource.h" + +#define APSTUDIO_READONLY_SYMBOLS +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 2 resource. +// +#include "winres.h" + +///////////////////////////////////////////////////////////////////////////// +#undef APSTUDIO_READONLY_SYMBOLS + +///////////////////////////////////////////////////////////////////////////// +// English (United States) resources + +#if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_ENU) +LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US + +#ifdef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// TEXTINCLUDE +// + +1 TEXTINCLUDE +BEGIN + "resource.h\0" +END + +2 TEXTINCLUDE +BEGIN + "#include ""winres.h""\r\n" + "\0" +END + +3 TEXTINCLUDE +BEGIN + "\r\n" + "\0" +END + +#endif // APSTUDIO_INVOKED + + +///////////////////////////////////////////////////////////////////////////// +// +// Icon +// + +// Icon with lowest ID value placed first to ensure application icon +// remains consistent on all systems. +IDI_APP_ICON ICON "resources\\app_icon.ico" + + +///////////////////////////////////////////////////////////////////////////// +// +// Version +// + +#if defined(FLUTTER_VERSION_MAJOR) && defined(FLUTTER_VERSION_MINOR) && defined(FLUTTER_VERSION_PATCH) && defined(FLUTTER_VERSION_BUILD) +#define VERSION_AS_NUMBER FLUTTER_VERSION_MAJOR,FLUTTER_VERSION_MINOR,FLUTTER_VERSION_PATCH,FLUTTER_VERSION_BUILD +#else +#define VERSION_AS_NUMBER 1,0,0,0 +#endif + +#if defined(FLUTTER_VERSION) +#define VERSION_AS_STRING FLUTTER_VERSION +#else +#define VERSION_AS_STRING "1.0.0" +#endif + +VS_VERSION_INFO VERSIONINFO + FILEVERSION VERSION_AS_NUMBER + PRODUCTVERSION VERSION_AS_NUMBER + FILEFLAGSMASK VS_FFI_FILEFLAGSMASK +#ifdef _DEBUG + FILEFLAGS VS_FF_DEBUG +#else + FILEFLAGS 0x0L +#endif + FILEOS VOS__WINDOWS32 + FILETYPE VFT_APP + FILESUBTYPE 0x0L +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904e4" + BEGIN + VALUE "CompanyName", "com.example" "\0" + VALUE "FileDescription", "devtools_companion" "\0" + VALUE "FileVersion", VERSION_AS_STRING "\0" + VALUE "InternalName", "devtools_companion" "\0" + VALUE "LegalCopyright", "Copyright (C) 2025 com.example. All rights reserved." "\0" + VALUE "OriginalFilename", "devtools_companion.exe" "\0" + VALUE "ProductName", "devtools_companion" "\0" + VALUE "ProductVersion", VERSION_AS_STRING "\0" + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1252 + END +END + +#endif // English (United States) resources +///////////////////////////////////////////////////////////////////////////// + + + +#ifndef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 3 resource. +// + + +///////////////////////////////////////////////////////////////////////////// +#endif // not APSTUDIO_INVOKED diff --git a/devexp_demos/devtools_companion/windows/runner/flutter_window.cpp b/devexp_demos/devtools_companion/windows/runner/flutter_window.cpp new file mode 100644 index 0000000..955ee30 --- /dev/null +++ b/devexp_demos/devtools_companion/windows/runner/flutter_window.cpp @@ -0,0 +1,71 @@ +#include "flutter_window.h" + +#include + +#include "flutter/generated_plugin_registrant.h" + +FlutterWindow::FlutterWindow(const flutter::DartProject& project) + : project_(project) {} + +FlutterWindow::~FlutterWindow() {} + +bool FlutterWindow::OnCreate() { + if (!Win32Window::OnCreate()) { + return false; + } + + RECT frame = GetClientArea(); + + // The size here must match the window dimensions to avoid unnecessary surface + // creation / destruction in the startup path. + flutter_controller_ = std::make_unique( + frame.right - frame.left, frame.bottom - frame.top, project_); + // Ensure that basic setup of the controller was successful. + if (!flutter_controller_->engine() || !flutter_controller_->view()) { + return false; + } + RegisterPlugins(flutter_controller_->engine()); + SetChildContent(flutter_controller_->view()->GetNativeWindow()); + + flutter_controller_->engine()->SetNextFrameCallback([&]() { + this->Show(); + }); + + // Flutter can complete the first frame before the "show window" callback is + // registered. The following call ensures a frame is pending to ensure the + // window is shown. It is a no-op if the first frame hasn't completed yet. + flutter_controller_->ForceRedraw(); + + return true; +} + +void FlutterWindow::OnDestroy() { + if (flutter_controller_) { + flutter_controller_ = nullptr; + } + + Win32Window::OnDestroy(); +} + +LRESULT +FlutterWindow::MessageHandler(HWND hwnd, UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + // Give Flutter, including plugins, an opportunity to handle window messages. + if (flutter_controller_) { + std::optional result = + flutter_controller_->HandleTopLevelWindowProc(hwnd, message, wparam, + lparam); + if (result) { + return *result; + } + } + + switch (message) { + case WM_FONTCHANGE: + flutter_controller_->engine()->ReloadSystemFonts(); + break; + } + + return Win32Window::MessageHandler(hwnd, message, wparam, lparam); +} diff --git a/devexp_demos/devtools_companion/windows/runner/flutter_window.h b/devexp_demos/devtools_companion/windows/runner/flutter_window.h new file mode 100644 index 0000000..6da0652 --- /dev/null +++ b/devexp_demos/devtools_companion/windows/runner/flutter_window.h @@ -0,0 +1,33 @@ +#ifndef RUNNER_FLUTTER_WINDOW_H_ +#define RUNNER_FLUTTER_WINDOW_H_ + +#include +#include + +#include + +#include "win32_window.h" + +// A window that does nothing but host a Flutter view. +class FlutterWindow : public Win32Window { + public: + // Creates a new FlutterWindow hosting a Flutter view running |project|. + explicit FlutterWindow(const flutter::DartProject& project); + virtual ~FlutterWindow(); + + protected: + // Win32Window: + bool OnCreate() override; + void OnDestroy() override; + LRESULT MessageHandler(HWND window, UINT const message, WPARAM const wparam, + LPARAM const lparam) noexcept override; + + private: + // The project to run. + flutter::DartProject project_; + + // The Flutter instance hosted by this window. + std::unique_ptr flutter_controller_; +}; + +#endif // RUNNER_FLUTTER_WINDOW_H_ diff --git a/devexp_demos/devtools_companion/windows/runner/main.cpp b/devexp_demos/devtools_companion/windows/runner/main.cpp new file mode 100644 index 0000000..64db46d --- /dev/null +++ b/devexp_demos/devtools_companion/windows/runner/main.cpp @@ -0,0 +1,43 @@ +#include +#include +#include + +#include "flutter_window.h" +#include "utils.h" + +int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, + _In_ wchar_t *command_line, _In_ int show_command) { + // Attach to console when present (e.g., 'flutter run') or create a + // new console when running with a debugger. + if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) { + CreateAndAttachConsole(); + } + + // Initialize COM, so that it is available for use in the library and/or + // plugins. + ::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); + + flutter::DartProject project(L"data"); + + std::vector command_line_arguments = + GetCommandLineArguments(); + + project.set_dart_entrypoint_arguments(std::move(command_line_arguments)); + + FlutterWindow window(project); + Win32Window::Point origin(10, 10); + Win32Window::Size size(1280, 720); + if (!window.Create(L"devtools_companion", origin, size)) { + return EXIT_FAILURE; + } + window.SetQuitOnClose(true); + + ::MSG msg; + while (::GetMessage(&msg, nullptr, 0, 0)) { + ::TranslateMessage(&msg); + ::DispatchMessage(&msg); + } + + ::CoUninitialize(); + return EXIT_SUCCESS; +} diff --git a/devexp_demos/devtools_companion/windows/runner/resource.h b/devexp_demos/devtools_companion/windows/runner/resource.h new file mode 100644 index 0000000..66a65d1 --- /dev/null +++ b/devexp_demos/devtools_companion/windows/runner/resource.h @@ -0,0 +1,16 @@ +//{{NO_DEPENDENCIES}} +// Microsoft Visual C++ generated include file. +// Used by Runner.rc +// +#define IDI_APP_ICON 101 + +// Next default values for new objects +// +#ifdef APSTUDIO_INVOKED +#ifndef APSTUDIO_READONLY_SYMBOLS +#define _APS_NEXT_RESOURCE_VALUE 102 +#define _APS_NEXT_COMMAND_VALUE 40001 +#define _APS_NEXT_CONTROL_VALUE 1001 +#define _APS_NEXT_SYMED_VALUE 101 +#endif +#endif diff --git a/devexp_demos/devtools_companion/windows/runner/resources/app_icon.ico b/devexp_demos/devtools_companion/windows/runner/resources/app_icon.ico new file mode 100644 index 0000000..c04e20c Binary files /dev/null and b/devexp_demos/devtools_companion/windows/runner/resources/app_icon.ico differ diff --git a/devexp_demos/devtools_companion/windows/runner/runner.exe.manifest b/devexp_demos/devtools_companion/windows/runner/runner.exe.manifest new file mode 100644 index 0000000..153653e --- /dev/null +++ b/devexp_demos/devtools_companion/windows/runner/runner.exe.manifest @@ -0,0 +1,14 @@ + + + + + PerMonitorV2 + + + + + + + + + diff --git a/devexp_demos/devtools_companion/windows/runner/utils.cpp b/devexp_demos/devtools_companion/windows/runner/utils.cpp new file mode 100644 index 0000000..3a0b465 --- /dev/null +++ b/devexp_demos/devtools_companion/windows/runner/utils.cpp @@ -0,0 +1,65 @@ +#include "utils.h" + +#include +#include +#include +#include + +#include + +void CreateAndAttachConsole() { + if (::AllocConsole()) { + FILE *unused; + if (freopen_s(&unused, "CONOUT$", "w", stdout)) { + _dup2(_fileno(stdout), 1); + } + if (freopen_s(&unused, "CONOUT$", "w", stderr)) { + _dup2(_fileno(stdout), 2); + } + std::ios::sync_with_stdio(); + FlutterDesktopResyncOutputStreams(); + } +} + +std::vector GetCommandLineArguments() { + // Convert the UTF-16 command line arguments to UTF-8 for the Engine to use. + int argc; + wchar_t** argv = ::CommandLineToArgvW(::GetCommandLineW(), &argc); + if (argv == nullptr) { + return std::vector(); + } + + std::vector command_line_arguments; + + // Skip the first argument as it's the binary name. + for (int i = 1; i < argc; i++) { + command_line_arguments.push_back(Utf8FromUtf16(argv[i])); + } + + ::LocalFree(argv); + + return command_line_arguments; +} + +std::string Utf8FromUtf16(const wchar_t* utf16_string) { + if (utf16_string == nullptr) { + return std::string(); + } + unsigned int target_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + -1, nullptr, 0, nullptr, nullptr) + -1; // remove the trailing null character + int input_length = (int)wcslen(utf16_string); + std::string utf8_string; + if (target_length == 0 || target_length > utf8_string.max_size()) { + return utf8_string; + } + utf8_string.resize(target_length); + int converted_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + input_length, utf8_string.data(), target_length, nullptr, nullptr); + if (converted_length == 0) { + return std::string(); + } + return utf8_string; +} diff --git a/devexp_demos/devtools_companion/windows/runner/utils.h b/devexp_demos/devtools_companion/windows/runner/utils.h new file mode 100644 index 0000000..3879d54 --- /dev/null +++ b/devexp_demos/devtools_companion/windows/runner/utils.h @@ -0,0 +1,19 @@ +#ifndef RUNNER_UTILS_H_ +#define RUNNER_UTILS_H_ + +#include +#include + +// Creates a console for the process, and redirects stdout and stderr to +// it for both the runner and the Flutter library. +void CreateAndAttachConsole(); + +// Takes a null-terminated wchar_t* encoded in UTF-16 and returns a std::string +// encoded in UTF-8. Returns an empty std::string on failure. +std::string Utf8FromUtf16(const wchar_t* utf16_string); + +// Gets the command line arguments passed in as a std::vector, +// encoded in UTF-8. Returns an empty std::vector on failure. +std::vector GetCommandLineArguments(); + +#endif // RUNNER_UTILS_H_ diff --git a/devexp_demos/devtools_companion/windows/runner/win32_window.cpp b/devexp_demos/devtools_companion/windows/runner/win32_window.cpp new file mode 100644 index 0000000..60608d0 --- /dev/null +++ b/devexp_demos/devtools_companion/windows/runner/win32_window.cpp @@ -0,0 +1,288 @@ +#include "win32_window.h" + +#include +#include + +#include "resource.h" + +namespace { + +/// Window attribute that enables dark mode window decorations. +/// +/// Redefined in case the developer's machine has a Windows SDK older than +/// version 10.0.22000.0. +/// See: https://docs.microsoft.com/windows/win32/api/dwmapi/ne-dwmapi-dwmwindowattribute +#ifndef DWMWA_USE_IMMERSIVE_DARK_MODE +#define DWMWA_USE_IMMERSIVE_DARK_MODE 20 +#endif + +constexpr const wchar_t kWindowClassName[] = L"FLUTTER_RUNNER_WIN32_WINDOW"; + +/// Registry key for app theme preference. +/// +/// A value of 0 indicates apps should use dark mode. A non-zero or missing +/// value indicates apps should use light mode. +constexpr const wchar_t kGetPreferredBrightnessRegKey[] = + L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize"; +constexpr const wchar_t kGetPreferredBrightnessRegValue[] = L"AppsUseLightTheme"; + +// The number of Win32Window objects that currently exist. +static int g_active_window_count = 0; + +using EnableNonClientDpiScaling = BOOL __stdcall(HWND hwnd); + +// Scale helper to convert logical scaler values to physical using passed in +// scale factor +int Scale(int source, double scale_factor) { + return static_cast(source * scale_factor); +} + +// Dynamically loads the |EnableNonClientDpiScaling| from the User32 module. +// This API is only needed for PerMonitor V1 awareness mode. +void EnableFullDpiSupportIfAvailable(HWND hwnd) { + HMODULE user32_module = LoadLibraryA("User32.dll"); + if (!user32_module) { + return; + } + auto enable_non_client_dpi_scaling = + reinterpret_cast( + GetProcAddress(user32_module, "EnableNonClientDpiScaling")); + if (enable_non_client_dpi_scaling != nullptr) { + enable_non_client_dpi_scaling(hwnd); + } + FreeLibrary(user32_module); +} + +} // namespace + +// Manages the Win32Window's window class registration. +class WindowClassRegistrar { + public: + ~WindowClassRegistrar() = default; + + // Returns the singleton registrar instance. + static WindowClassRegistrar* GetInstance() { + if (!instance_) { + instance_ = new WindowClassRegistrar(); + } + return instance_; + } + + // Returns the name of the window class, registering the class if it hasn't + // previously been registered. + const wchar_t* GetWindowClass(); + + // Unregisters the window class. Should only be called if there are no + // instances of the window. + void UnregisterWindowClass(); + + private: + WindowClassRegistrar() = default; + + static WindowClassRegistrar* instance_; + + bool class_registered_ = false; +}; + +WindowClassRegistrar* WindowClassRegistrar::instance_ = nullptr; + +const wchar_t* WindowClassRegistrar::GetWindowClass() { + if (!class_registered_) { + WNDCLASS window_class{}; + window_class.hCursor = LoadCursor(nullptr, IDC_ARROW); + window_class.lpszClassName = kWindowClassName; + window_class.style = CS_HREDRAW | CS_VREDRAW; + window_class.cbClsExtra = 0; + window_class.cbWndExtra = 0; + window_class.hInstance = GetModuleHandle(nullptr); + window_class.hIcon = + LoadIcon(window_class.hInstance, MAKEINTRESOURCE(IDI_APP_ICON)); + window_class.hbrBackground = 0; + window_class.lpszMenuName = nullptr; + window_class.lpfnWndProc = Win32Window::WndProc; + RegisterClass(&window_class); + class_registered_ = true; + } + return kWindowClassName; +} + +void WindowClassRegistrar::UnregisterWindowClass() { + UnregisterClass(kWindowClassName, nullptr); + class_registered_ = false; +} + +Win32Window::Win32Window() { + ++g_active_window_count; +} + +Win32Window::~Win32Window() { + --g_active_window_count; + Destroy(); +} + +bool Win32Window::Create(const std::wstring& title, + const Point& origin, + const Size& size) { + Destroy(); + + const wchar_t* window_class = + WindowClassRegistrar::GetInstance()->GetWindowClass(); + + const POINT target_point = {static_cast(origin.x), + static_cast(origin.y)}; + HMONITOR monitor = MonitorFromPoint(target_point, MONITOR_DEFAULTTONEAREST); + UINT dpi = FlutterDesktopGetDpiForMonitor(monitor); + double scale_factor = dpi / 96.0; + + HWND window = CreateWindow( + window_class, title.c_str(), WS_OVERLAPPEDWINDOW, + Scale(origin.x, scale_factor), Scale(origin.y, scale_factor), + Scale(size.width, scale_factor), Scale(size.height, scale_factor), + nullptr, nullptr, GetModuleHandle(nullptr), this); + + if (!window) { + return false; + } + + UpdateTheme(window); + + return OnCreate(); +} + +bool Win32Window::Show() { + return ShowWindow(window_handle_, SW_SHOWNORMAL); +} + +// static +LRESULT CALLBACK Win32Window::WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + if (message == WM_NCCREATE) { + auto window_struct = reinterpret_cast(lparam); + SetWindowLongPtr(window, GWLP_USERDATA, + reinterpret_cast(window_struct->lpCreateParams)); + + auto that = static_cast(window_struct->lpCreateParams); + EnableFullDpiSupportIfAvailable(window); + that->window_handle_ = window; + } else if (Win32Window* that = GetThisFromHandle(window)) { + return that->MessageHandler(window, message, wparam, lparam); + } + + return DefWindowProc(window, message, wparam, lparam); +} + +LRESULT +Win32Window::MessageHandler(HWND hwnd, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + switch (message) { + case WM_DESTROY: + window_handle_ = nullptr; + Destroy(); + if (quit_on_close_) { + PostQuitMessage(0); + } + return 0; + + case WM_DPICHANGED: { + auto newRectSize = reinterpret_cast(lparam); + LONG newWidth = newRectSize->right - newRectSize->left; + LONG newHeight = newRectSize->bottom - newRectSize->top; + + SetWindowPos(hwnd, nullptr, newRectSize->left, newRectSize->top, newWidth, + newHeight, SWP_NOZORDER | SWP_NOACTIVATE); + + return 0; + } + case WM_SIZE: { + RECT rect = GetClientArea(); + if (child_content_ != nullptr) { + // Size and position the child window. + MoveWindow(child_content_, rect.left, rect.top, rect.right - rect.left, + rect.bottom - rect.top, TRUE); + } + return 0; + } + + case WM_ACTIVATE: + if (child_content_ != nullptr) { + SetFocus(child_content_); + } + return 0; + + case WM_DWMCOLORIZATIONCOLORCHANGED: + UpdateTheme(hwnd); + return 0; + } + + return DefWindowProc(window_handle_, message, wparam, lparam); +} + +void Win32Window::Destroy() { + OnDestroy(); + + if (window_handle_) { + DestroyWindow(window_handle_); + window_handle_ = nullptr; + } + if (g_active_window_count == 0) { + WindowClassRegistrar::GetInstance()->UnregisterWindowClass(); + } +} + +Win32Window* Win32Window::GetThisFromHandle(HWND const window) noexcept { + return reinterpret_cast( + GetWindowLongPtr(window, GWLP_USERDATA)); +} + +void Win32Window::SetChildContent(HWND content) { + child_content_ = content; + SetParent(content, window_handle_); + RECT frame = GetClientArea(); + + MoveWindow(content, frame.left, frame.top, frame.right - frame.left, + frame.bottom - frame.top, true); + + SetFocus(child_content_); +} + +RECT Win32Window::GetClientArea() { + RECT frame; + GetClientRect(window_handle_, &frame); + return frame; +} + +HWND Win32Window::GetHandle() { + return window_handle_; +} + +void Win32Window::SetQuitOnClose(bool quit_on_close) { + quit_on_close_ = quit_on_close; +} + +bool Win32Window::OnCreate() { + // No-op; provided for subclasses. + return true; +} + +void Win32Window::OnDestroy() { + // No-op; provided for subclasses. +} + +void Win32Window::UpdateTheme(HWND const window) { + DWORD light_mode; + DWORD light_mode_size = sizeof(light_mode); + LSTATUS result = RegGetValue(HKEY_CURRENT_USER, kGetPreferredBrightnessRegKey, + kGetPreferredBrightnessRegValue, + RRF_RT_REG_DWORD, nullptr, &light_mode, + &light_mode_size); + + if (result == ERROR_SUCCESS) { + BOOL enable_dark_mode = light_mode == 0; + DwmSetWindowAttribute(window, DWMWA_USE_IMMERSIVE_DARK_MODE, + &enable_dark_mode, sizeof(enable_dark_mode)); + } +} diff --git a/devexp_demos/devtools_companion/windows/runner/win32_window.h b/devexp_demos/devtools_companion/windows/runner/win32_window.h new file mode 100644 index 0000000..e901dde --- /dev/null +++ b/devexp_demos/devtools_companion/windows/runner/win32_window.h @@ -0,0 +1,102 @@ +#ifndef RUNNER_WIN32_WINDOW_H_ +#define RUNNER_WIN32_WINDOW_H_ + +#include + +#include +#include +#include + +// A class abstraction for a high DPI-aware Win32 Window. Intended to be +// inherited from by classes that wish to specialize with custom +// rendering and input handling +class Win32Window { + public: + struct Point { + unsigned int x; + unsigned int y; + Point(unsigned int x, unsigned int y) : x(x), y(y) {} + }; + + struct Size { + unsigned int width; + unsigned int height; + Size(unsigned int width, unsigned int height) + : width(width), height(height) {} + }; + + Win32Window(); + virtual ~Win32Window(); + + // Creates a win32 window with |title| that is positioned and sized using + // |origin| and |size|. New windows are created on the default monitor. Window + // sizes are specified to the OS in physical pixels, hence to ensure a + // consistent size this function will scale the inputted width and height as + // as appropriate for the default monitor. The window is invisible until + // |Show| is called. Returns true if the window was created successfully. + bool Create(const std::wstring& title, const Point& origin, const Size& size); + + // Show the current window. Returns true if the window was successfully shown. + bool Show(); + + // Release OS resources associated with window. + void Destroy(); + + // Inserts |content| into the window tree. + void SetChildContent(HWND content); + + // Returns the backing Window handle to enable clients to set icon and other + // window properties. Returns nullptr if the window has been destroyed. + HWND GetHandle(); + + // If true, closing this window will quit the application. + void SetQuitOnClose(bool quit_on_close); + + // Return a RECT representing the bounds of the current client area. + RECT GetClientArea(); + + protected: + // Processes and route salient window messages for mouse handling, + // size change and DPI. Delegates handling of these to member overloads that + // inheriting classes can handle. + virtual LRESULT MessageHandler(HWND window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Called when CreateAndShow is called, allowing subclass window-related + // setup. Subclasses should return false if setup fails. + virtual bool OnCreate(); + + // Called when Destroy is called. + virtual void OnDestroy(); + + private: + friend class WindowClassRegistrar; + + // OS callback called by message pump. Handles the WM_NCCREATE message which + // is passed when the non-client area is being created and enables automatic + // non-client DPI scaling so that the non-client area automatically + // responds to changes in DPI. All other messages are handled by + // MessageHandler. + static LRESULT CALLBACK WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Retrieves a class instance pointer for |window| + static Win32Window* GetThisFromHandle(HWND const window) noexcept; + + // Update the window frame's theme to match the system theme. + static void UpdateTheme(HWND const window); + + bool quit_on_close_ = false; + + // window handle for top level window. + HWND window_handle_ = nullptr; + + // window handle for hosted content. + HWND child_content_ = nullptr; +}; + +#endif // RUNNER_WIN32_WINDOW_H_ diff --git a/docs/CNAME b/docs/CNAME new file mode 100644 index 0000000..590a2f2 --- /dev/null +++ b/docs/CNAME @@ -0,0 +1 @@ +home.goo.gle \ No newline at end of file diff --git a/firebase_ai_logic_showcase/README.md b/firebase_ai_logic_showcase/README.md index a627f27..29485f4 100644 --- a/firebase_ai_logic_showcase/README.md +++ b/firebase_ai_logic_showcase/README.md @@ -71,11 +71,11 @@ between different Gemini models. - **Attach an image (optional):** Tap the "Image" icon to select an image from your gallery to send with your message. - **View conversation:** Your messages and Gemini's responses will appear in the chat history. -- **Use tools with function calling:** With `gemini-2.5-flash` (selected by default), +- **Use tools with function calling:** With `gemini-3.5-flash` (selected by default), you can ask Gemini to use the two tools that are built into the demo: - Generate an image using Imagen (e.g., "generate an image of a cat") or - Change the color of the app (e.g., "change the color to blue"). -- **Nano Banana** With `gemini-2.5-flash-image`, you can generate and edit images. +- **Nano Banana** With `gemini-3.1-flash-image-preview`, you can generate and edit images. - **Generate an image:** Enter a text prompt and generate a new image. - **Edit an image:** Provide instructions for Gemini to edit a previously generated image or select one from your photo library. diff --git a/firebase_ai_logic_showcase/lib/demos/chat/chat_demo.dart b/firebase_ai_logic_showcase/lib/demos/chat/chat_demo.dart index 63aaf84..f6b63e2 100644 --- a/firebase_ai_logic_showcase/lib/demos/chat/chat_demo.dart +++ b/firebase_ai_logic_showcase/lib/demos/chat/chat_demo.dart @@ -48,7 +48,7 @@ class _ChatDemoState extends ConsumerState { @override void initState() { super.initState(); - final model = geminiModels.selectModel('gemini-2.5-flash'); + final model = geminiModels.selectModel('gemini-3.5-flash'); _chatService = ChatService(ref, model); _chatService.init(); _userTextInputController.text = diff --git a/firebase_ai_logic_showcase/lib/demos/multimodal/firebaseai_multimodal_service.dart b/firebase_ai_logic_showcase/lib/demos/multimodal/firebaseai_multimodal_service.dart index 39170e8..40df704 100644 --- a/firebase_ai_logic_showcase/lib/demos/multimodal/firebaseai_multimodal_service.dart +++ b/firebase_ai_logic_showcase/lib/demos/multimodal/firebaseai_multimodal_service.dart @@ -27,7 +27,7 @@ import 'models/attachment.dart'; /// https://firebase.google.com/docs/ai-logic/generate-text?api=dev#base64 class MultimodalService { final _model = FirebaseAI.googleAI().generativeModel( - model: 'gemini-2.5-flash', + model: 'gemini-3.5-flash', ); /// Generates text content from a text prompt and a file attachment. diff --git a/firebase_ai_logic_showcase/lib/demos/multimodal/ui_components/attachment_view.dart b/firebase_ai_logic_showcase/lib/demos/multimodal/ui_components/attachment_view.dart index f4ac3d3..b12e214 100644 --- a/firebase_ai_logic_showcase/lib/demos/multimodal/ui_components/attachment_view.dart +++ b/firebase_ai_logic_showcase/lib/demos/multimodal/ui_components/attachment_view.dart @@ -13,7 +13,6 @@ // limitations under the License. import 'package:flutter/material.dart'; -import 'package:thumbnailer/thumbnailer.dart'; import '../../../shared/ui/app_spacing.dart'; import '../models/attachment.dart'; @@ -50,18 +49,7 @@ class AttachmentView extends StatelessWidget { : Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Thumbnail( - decoration: WidgetDecoration( - wrapperSize: 90, - iconColor: Theme.of(context).colorScheme.primaryFixed, - ), - mimeType: attachment!.mimeType, - onlyIcon: true, - dataResolver: () async { - return attachment!.fileBytes; - }, - widgetSize: 200, - ), + _buildPreview(context), const SizedBox.square(dimension: AppSpacing.s16), Padding( padding: const EdgeInsets.symmetric(horizontal: AppSpacing.s16), @@ -69,12 +57,162 @@ class AttachmentView extends StatelessWidget { attachment!.fileName, style: TextStyle( fontSize: 16, + fontWeight: FontWeight.w600, color: Theme.of(context).colorScheme.onSurface, ), textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, ), ), ], ); } + + Widget _buildPreview(BuildContext context) { + final mimeType = attachment!.mimeType.toLowerCase(); + + if (mimeType.startsWith('image/')) { + return Container( + width: 160, + height: 160, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.1), + blurRadius: 10, + spreadRadius: 1, + offset: const Offset(0, 4), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(16), + child: Image.memory( + attachment!.fileBytes, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) => _buildIconFallback( + context, + Icons.broken_image, + Colors.red, + 'Image', + ), + ), + ), + ); + } + + if (mimeType.contains('pdf')) { + return _buildIconFallback( + context, + Icons.picture_as_pdf, + Colors.red.shade600, + 'PDF', + ); + } + + if (mimeType.startsWith('video/')) { + return _buildIconFallback( + context, + Icons.video_file, + Colors.indigo.shade600, + 'Video', + ); + } + + if (mimeType.startsWith('audio/')) { + return _buildIconFallback( + context, + Icons.audiotrack, + Colors.amber.shade700, + 'Audio', + ); + } + + if (mimeType.startsWith('text/') || + mimeType.contains('json') || + mimeType.contains('javascript') || + mimeType.contains('xml')) { + return _buildIconFallback( + context, + Icons.code, + Colors.teal.shade600, + 'Code', + ); + } + + // Default document fallback + return _buildIconFallback( + context, + Icons.insert_drive_file, + Colors.blueGrey.shade600, + 'File', + ); + } + + Widget _buildIconFallback( + BuildContext context, + IconData icon, + Color primaryColor, + String label, + ) { + return Container( + width: 160, + height: 160, + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + primaryColor.withOpacity(0.15), + primaryColor.withOpacity(0.05), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(24), + border: Border.all( + color: primaryColor.withOpacity(0.3), + width: 1.5, + ), + boxShadow: [ + BoxShadow( + color: primaryColor.withOpacity(0.05), + blurRadius: 12, + spreadRadius: 2, + offset: const Offset(0, 4), + ), + ], + ), + child: Stack( + alignment: Alignment.center, + children: [ + Icon( + icon, + size: 64, + color: primaryColor, + ), + Positioned( + bottom: 12, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: primaryColor, + borderRadius: BorderRadius.circular(12), + ), + child: Text( + label.toUpperCase(), + style: const TextStyle( + color: Colors.white, + fontSize: 10, + fontWeight: FontWeight.w800, + letterSpacing: 1.2, + ), + ), + ), + ), + ], + ), + ); + } } + diff --git a/firebase_ai_logic_showcase/lib/flutter_firebase_ai_demo.dart b/firebase_ai_logic_showcase/lib/flutter_firebase_ai_demo.dart index d9fde64..a500f9e 100644 --- a/firebase_ai_logic_showcase/lib/flutter_firebase_ai_demo.dart +++ b/firebase_ai_logic_showcase/lib/flutter_firebase_ai_demo.dart @@ -27,27 +27,28 @@ class DemoHomeScreen extends StatefulWidget { class _DemoHomeScreenState extends State { int _selectedIndex = 0; - final List<({Widget icon, String label, Widget? selectedIcon})> destinations = - [ - (icon: const Icon(Icons.chat), label: 'Chat', selectedIcon: null), + final List<({Widget icon, String label, Widget? selectedIcon})> + destinations = [ + (icon: const Icon(Icons.chat), label: 'Chat', selectedIcon: null), + /* TODO: This demo should remain inactive until Appcheck is enforced by default @cynthiajoan ( icon: const Icon(Icons.video_chat), label: 'Live API', selectedIcon: null, - ), - ( - icon: const Icon(Icons.photo_library), - label: 'Multimodal', - selectedIcon: null, - ), - ( - icon: RichText( - text: const TextSpan(style: TextStyle(fontSize: 24.0), text: '🍌'), - ), - label: 'Nano Banana', - selectedIcon: null, - ), - ]; + ),*/ + ( + icon: const Icon(Icons.photo_library), + label: 'Multimodal', + selectedIcon: null, + ), + ( + icon: RichText( + text: const TextSpan(style: TextStyle(fontSize: 24.0), text: '🍌'), + ), + label: 'Nano Banana', + selectedIcon: null, + ), + ]; void _onItemTapped(int index) { setState(() { @@ -122,7 +123,7 @@ class _DemoHomeScreenState extends State { final List demoPages = [ const ChatDemo(), - LiveAPIDemo(), + // TODO: This demo should remain inactive until Appcheck is enforced by default @cynthiajoan LiveAPIDemo(), const MultimodalDemo(), ChatDemoNano(), ]; diff --git a/firebase_ai_logic_showcase/lib/shared/firebaseai_imagen_service.dart b/firebase_ai_logic_showcase/lib/shared/firebaseai_imagen_service.dart index 3f0b663..c251284 100644 --- a/firebase_ai_logic_showcase/lib/shared/firebaseai_imagen_service.dart +++ b/firebase_ai_logic_showcase/lib/shared/firebaseai_imagen_service.dart @@ -27,7 +27,7 @@ import 'package:firebase_ai/firebase_ai.dart'; /// generation capabilities. class ImageGenerationService { final _model = FirebaseAI.googleAI().generativeModel( - model: 'gemini-2.5-flash-image-preview', + model: 'gemini-3.1-flash-image-preview', generationConfig: GenerationConfig( responseModalities: [ResponseModalities.image], ), diff --git a/firebase_ai_logic_showcase/lib/shared/models/gemini_model.dart b/firebase_ai_logic_showcase/lib/shared/models/gemini_model.dart index 08efdd1..627e588 100644 --- a/firebase_ai_logic_showcase/lib/shared/models/gemini_model.dart +++ b/firebase_ai_logic_showcase/lib/shared/models/gemini_model.dart @@ -32,17 +32,17 @@ class GeminiModel { } class GeminiModels { - String selectedModelName = 'gemini-2.5-flash-image-preview'; + String selectedModelName = 'gemini-3.1-flash-image-preview'; GeminiModel get selectedModel => models[selectedModelName]!; /// A map of Gemini models that can be used in the Chat Demo. Map models = { - 'gemini-2.5-flash': GeminiModel( - name: 'gemini-2.5-flash', + 'gemini-3.5-flash': GeminiModel( + name: 'gemini-3.5-flash', description: 'Our thinking model that offers great, well-rounded capabilities. It\'s designed to offer a balance between price and performance.', model: FirebaseAI.googleAI().generativeModel( - model: 'gemini-2.5-flash', + model: 'gemini-3.5-flash', tools: [ Tool.functionDeclarations([setAppColorTool, generateImageTool]), ], @@ -52,12 +52,12 @@ class GeminiModels { ), defaultPrompt: 'Hey Gemini! Can you set the app color to purple?', ), - 'gemini-2.5-flash-image': GeminiModel( - name: 'gemini-2.5-flash-image', + 'gemini-3.1-flash-image-preview': GeminiModel( + name: 'gemini-3.1-flash-image-preview', description: 'Our standard Flash model upgraded for rapid creative workflows with image generation and conversational, multi-turn editing capabilities.', model: FirebaseAI.googleAI().generativeModel( - model: 'gemini-2.5-flash-image', + model: 'gemini-3.1-flash-image-preview', generationConfig: GenerationConfig( responseModalities: [ ResponseModalities.text, diff --git a/firebase_ai_logic_showcase/pubspec.yaml b/firebase_ai_logic_showcase/pubspec.yaml index abf565e..85bf715 100644 --- a/firebase_ai_logic_showcase/pubspec.yaml +++ b/firebase_ai_logic_showcase/pubspec.yaml @@ -23,11 +23,11 @@ dependencies: permission_handler: ^12.0.1 flutter_markdown: ^0.7.7+1 file_picker: ^10.2.1 - thumbnailer: ^3.1.0 cross_file: ^0.3.4+2 mime: ^2.0.0 url_launcher: ^6.3.2 + dev_dependencies: flutter_test: sdk: flutter @@ -38,3 +38,5 @@ flutter: assets: - assets/gemini-logo.png - assets/firebase-ai-logic.png + + diff --git a/flutter-frontend-for-adk/README.md b/flutter-frontend-for-adk/README.md new file mode 100644 index 0000000..41dd54e --- /dev/null +++ b/flutter-frontend-for-adk/README.md @@ -0,0 +1,84 @@ +# Flutter Frontend for ADK Agents (Custom Skill) + +This repository contains a skill intended for use with [Antigravity](https://antigravity.google). It will guide the agent through the discovery, architecture, design, and implementation of a Flutter-based frontend for an Agent Development Kit (ADK) agent written in Python. + +> [!NOTE] +> This repository is a learning resource and an open-source sample. Use it with an experimental project as a way of learning how to use Antigravity to get things done with Flutter. + +--- + +## Capabilities + +The `flutter-frontend-for-adk` skill orchestrates a structured, multi-phase workflow to construct a responsive Flutter client that interfaces with an ADK Python agent: + +1. **Workspace & Agent Discovery:** Maps the agent's purpose, sub-agent hierarchy, API endpoints, event streams, and human-in-the-loop triggers. +2. **Frontend Usage & Behavior:** Defines target platforms (mobile, web, desktop), user experience flows, and screen requirements. +3. **Frontend Architecture:** Establishes directory structure, state management using the `provider` package, and manual JSON serialization models. +4. **Frontend Design & Layout:** Configures visual styles, responsive column layouts, interactive states, and micro-animations. +5. **Scaffolding & Implementation:** Scaffolds the Flutter project, implements the API service (handling REST and Server-Sent Events/SSE streams), and builds the views. +6. **Workspace Integration:** Integrates frontend execution commands into workspace build tools (e.g., Makefiles, script runners) and updates version control settings. + +--- + +## Installation + +To make this skill available to Antigravity, you can install it either globally or locally within a specific workspace. + +### Global Installation + +To make the skill available across all your projects, copy this repository into your global Antigravity customizations directory: + +```bash +mkdir -p ~/.gemini/config/skills/flutter-frontend-for-adk +cp -r . ~/.gemini/config/skills/flutter-frontend-for-adk +``` + +### Local Project Installation + +To make the skill available only within a specific project, copy this repository into the `.agents/skills` directory at the root of that project: + +```bash +mkdir -p /path/to/your/project/.agents/skills/flutter-frontend-for-adk +cp -r . /path/to/your/project/.agents/skills/flutter-frontend-for-adk +``` + +Once copied, Antigravity will automatically discover the skill via its `SKILL.md` definition. + +--- + +## Step-by-Step Usage Example + +This section outlines how to use the skill to replace an existing web frontend with a Flutter client, using the official `deep_search` ADK sample agent as an example. + +### 1. Clone the ADK Samples Repository +Clone the official repository containing the Agent Development Kit samples to your local machine: + +```bash +git clone https://github.com/google/adk-samples.git +cd adk-samples +``` + +### 2. Open the Target Sample +Navigate to the Python version of the `deep_search` sample and open it in your workspace: + +```bash +cd python/deep_search +``` + +Ensure this directory is open in your IDE workspace where Antigravity is active. + +### 3. Remove the Existing Frontend +Prompt Antigravity to remove the React-based frontend that is bundled with the sample. This ensures a clean slate for the new Flutter application. + +You can use a prompt such as: +> "Please remove the React frontend that ships with this sample, along with any mention of it in associated documentation, configuration files, and build scripts." + +Antigravity will delete the React source files, remove web-related dependencies, and clean up workspace references. + +### 4. Generate the Flutter Frontend +Once the workspace is cleaned, instruct Antigravity to use the installed custom skill to build a new Flutter frontend. + +You can use a prompt such as: +> "Please use the `flutter-frontend-for-adk` skill to create a new, Flutter-based frontend for the agent." + +Antigravity will activate the skill and proceed through the six phases described in the [SKILL.md](file:///Users/redbrogdon/source/demos/flutter-frontend-for-adk/SKILL.md) file, prompting you for input and approval at the end of each phase before continuing to the next. diff --git a/flutter-frontend-for-adk/SKILL.md b/flutter-frontend-for-adk/SKILL.md new file mode 100644 index 0000000..5e44462 --- /dev/null +++ b/flutter-frontend-for-adk/SKILL.md @@ -0,0 +1,85 @@ +--- +name: flutter-frontend-for-adk +description: > + Orchestrates the discovery, mapping, design, and implementation of a premium + Flutter-based frontend for an Agent Development Kit (ADK) agent written with Python. +metadata: + author: Antigravity + version: 1.0.0 + requires: + bins: + - flutter + - dart +--- + +# Building a Flutter Frontend for an ADK Agent + +This skill orchestrates the end-to-end workflow of designing, specifying, and implementing a premium Flutter-based frontend for any Agent Development Kit (ADK) agent. + +## Prerequisites & External References + +Before starting, the developer or coding agent should locate ADK framework-level specifications using the following resources instead of restating them in this skill: +1. **Activate ADK Code Skill:** Ensure that `/google-agents-cli-adk-code` is active in the current session. Refer to its reference file `adk-python.md` to understand the core `Event`, `EventActions`, and state management models. +2. **Fetch Official Docs:** Retrieve the official documentation index by calling `curl https://adk.dev/llms.txt` and search for pages related to client serving, networking, or the Python API server. +3. **Inspect Local Code:** To see exact Pydantic definitions and FastAPI endpoint details directly: + * Open and inspect the local server definition inside your python environment at `.venv/lib/python*/site-packages/google/adk/cli/adk_web_server.py`. + * Open and inspect the event schemas at `.venv/lib/python*/site-packages/google/adk/events/event.py` and `event_actions.py`. + +Follow the phases below sequentially. **Do not skip ahead.** You must complete and record the findings of each phase before beginning the next. When creating an implementation plan, add a blocking task for the "Review" step in each phase that has one and wait for the user to perform a review before beginning the next phase. + +--- + +## Phase 1: Workspace & Agent Discovery +Identify the core purpose of the agent in this repository, understand its sub-agent hierarchy, map its API endpoints, event streams, and human-in-the-loop triggers, and record this information. +* **Action:** Read [references/agent_discovery.md](references/agent_discovery.md) for step-by-step discovery instructions. +* **Deliverable:** Create the [AGENT_INTERFACE_NOTES.md](AGENT_INTERFACE_NOTES.md) file in the root of the project. +* **Review:** Summarize (in a paragraph or two) what you just learned for the user. Invite them to inspect `AGENT_DISCOVERY_NOTES.md`, and do not proceed to Phase 2 until they indicate that you should do so. + +--- + +## Phase 2: Frontend Usage & Behavior +Define the high-level behavioral and functional requirements of the frontend: target platforms, screen specifications, feature details, and user experience flows. +* **Action:** Read [references/frontend_usage.md](references/frontend_usage.md) for instructions on defining app behavior. +* **Deliverable:** Create the [FRONTEND_USAGE_NOTES.md](FRONTEND_USAGE_NOTES.md) file in the root of the project. +* **Review:** Summarize (in a paragraph or two) what you just learned for the user. Invite them to inspect `FRONTEND_USAGE_NOTES.md`, and do not proceed to Phase 3 until they indicate that you should do so. + +--- + +## Phase 3: Frontend Architecture +Define the structural patterns of the application, including core folder directory layouts, manual model serializations, and state management setups. +* **Action:** Read [references/frontend_architecture.md](references/frontend_architecture.md) for instructions on defining app architecture. +* **Deliverable:** Create the [FRONTEND_ARCHITECTURE_NOTES.md](FRONTEND_ARCHITECTURE_NOTES.md) file in the root of the project. +* **Review:** Summarize (in a paragraph or two) what you just learned for the user. Invite them to inspect `FRONTEND_ARCHITECTURE_NOTES.md`, and do not proceed to Phase 4 until they indicate that you should do so. + +--- + +## Phase 4: Frontend Design & Layout +Design the visual structure, layout columns, styling guides, states, and micro-animations for the frontend application. +* **Action:** Read [references/frontend_design.md](references/frontend_design.md) for styling and UI blueprints. +* **Deliverable:** Create the [FRONTEND_DESIGN_NOTES.md](FRONTEND_DESIGN_NOTES.md) file in the root of the project. +* **Review:** Summarize (in a paragraph or two) what you just learned for the user. Invite them to inspect `FRONTEND_DESIGN_NOTES.md`, and do not proceed to Phase 5 until they indicate that you should do so. + +---- + +## Phase 5: Scaffolding & Implementation +Create and program the Flutter client application within the workspace, implementing all designs and specifications. +* **Action:** + 1. Read [references/frontend_best_practices.md](references/frontend_best_practices.md) for specific implementation patterns and best practices. + 2. Scaffold a fresh Flutter project inside a folder named `frontend/` at the root of the workspace. Do not specify an org name unless instructed. Create the app to build for only those platforms specified by the user -- if you're not sure, ask. + 3. Add the approved packages (`provider`, `flutter_markdown`, `url_launcher`, and `http`) to `frontend/pubspec.yaml`. + 4. Write the manual serialization models, the `AdkApiService` (implementing standard REST and SSE HTTP streamed response parsers), the `AgentProvider` state notifier, and the responsive views/widgets following `FRONTEND_ARCHITECTURE_NOTES.md` and `FRONTEND_DESIGN_NOTES.md`. + 5. Verify code cleanliness and correctness by running `flutter format`, `flutter analyze`, and `flutter test` inside the `frontend/` directory. +* **Deliverable:** A fully compiled and functional Flutter application located inside the `frontend/` folder. + +--- + +## Phase 6: Workspace Integration & Documentation +Integrate the frontend into the workspace build flows, update version control settings, and document the client setup for developers. +* **Action:** + 1. **Build Configuration:** Examine the codebase to determine how a developer builds and runs the backend agent (e.g. searching for `Makefile` targets, task configurations in `pyproject.toml` like Poe/Taskipy, `package.json` scripts, or custom `run.sh` / `build.sh` scripts). Update these automation tools or files to provide simple commands for developers to build, test, and launch the new Flutter frontend. + 2. **VCS Ignore Setup:** Update the project's version control ignore configurations to make sure they account for the new project and its artifacts. The Flutter frontend should have a nested `.gitignore` file that will account for most things. If the root `.gitignore` file ignores `lib/`, though, make sure to add an exclusion for Flutter's `lib` directory (`!frontend/lib`). + 3. **Documentation Update:** + * If the project has a `README.md` in the root directory, update it to reference the new frontend, detail prerequisites (such as the Flutter SDK version), and outline startup instructions. + * Update the default `frontend/README.md` file to describe the frontend client, its structure, and how to launch both the agent and the client using the build configuration tools updated in step 1. + * Look for any additional pre-existing docs that might need to be updated and do so. +* **Deliverable:** Updated workspace integration configurations, build scripts, and documentation files. diff --git a/flutter-frontend-for-adk/references/agent_discovery.md b/flutter-frontend-for-adk/references/agent_discovery.md new file mode 100644 index 0000000..7423ff0 --- /dev/null +++ b/flutter-frontend-for-adk/references/agent_discovery.md @@ -0,0 +1,94 @@ +# Agent Discovery & Mapping Reference + +This document directs the agent or developer to analyze the target ADK workspace and compile the `AGENT_INTERFACE_NOTES.md` file. + +--- + +## Phase 1: Workspace & Agent Discovery + +Before writing any frontend code, you must analyze the backend agent to understand its capabilities, state variables, and execution patterns. + +### 1. Identify Workspace Entrypoints +* Scan the root directory for documentation (`README.md`, developer specs) to grasp the core purpose of the agent. +* Locate the startup configurations, usually defined in a `Makefile` or `pyproject.toml` (e.g., look for `adk api_server`). + +### 2. Deep Dive Into Backend Agent Logic +Analyze the core agent definition file (typically `app/agent.py` or similar) to map out: +* **The Execution Timeline (Agent Authors):** + * Identify the `root_agent` (the primary coordinator that the server communicates with directly). + * Trace all sub-agents (e.g., in sequential structures, loop structures, or conditional routing trees). + * Note the exact `name` string of every agent. The agent's `name` string will be the value populated in the `author` field of incoming `Event` stream items. Construct a chronological list of these authors representing the pipeline execution flow. +* **State Keys & Custom Callbacks:** + * Identify agent-specific state keys by searching for `output_key="key_name"` parameters on all agent instantiations. + * Search the file for custom callbacks (functions containing parameters of type `ToolContext` or `CallbackContext`). + * Inspect these callback bodies for references to the session's state dictionary (e.g., `callback_context.state["key_name"]` or `tool_context.state["key_name"]`). + * Identify where these callbacks are registered (e.g., `before_agent_callback`, `after_agent_callback`, `after_model_callback`) on specific agents to understand exactly *when* these state variables are updated during the execution sequence. + +### 3. Determine Server Execution Flags +* Examine how the backend API server is run locally. +* Run the command with `--help` (e.g., `uv run adk api_server --help`) or check active configs to determine: + * Port and host configurations (default is `http://127.0.0.1:8000`). + * CORS origin settings (`--allow_origins`). + * Session and artifact persistence backends (e.g., `--session_service_uri`, `--artifact_service_uri`). + +### 4. Record Your Findings +* Create a new file in the root of the project called `AGENT_INTERFACE_NOTES.md`. +* In `AGENT_INTERFACE_NOTES.md` create the following sections: + 1. **Introduction**: a description of the agent, including its name, what its intended purpose is, and a rough description of how it is constructed. + 2. **Agent Entrypoint**: A description of the entrypoint of the agent, including the exact command used to start the agent locally. + 3. **Key Components**: A description of each sub-agent in the agent, including its name, class, and a description of what it does. + +--- + +## Phase 2: ADK API & Event Mapping + +The ADK API server automatically wraps the agent and exposes standard HTTP and streaming endpoints. You must fully understand how communication with the agent works. To do so, you will read and analyze the agent's code, and then record your findings in additional sections in `AGENT_INTERFACE_NOTES.md`. + +### 1. Understand API Endpoints +ADK-based agents expose a REST API that can be used to communicate with the agent. The API is documented in the ADK documentation, but you should also read the agent's code to fully understand how it works. Here are the key endpoints: + +* **Run Agent (Blocking):** `POST /run` + * Executes the agent and returns a complete list of events after completion. +* **Run Agent (SSE Streaming):** `POST /run_sse` + * Establishes a Server-Sent Events stream of events. Used for live chat responses and progress indicators. +* **Session Management:** `GET/POST/DELETE` endpoints under `/apps/{app_name}/users/{user_id}/sessions` to list, create, or delete user sessions. +* **Artifact Loading:** `GET /apps/{app_name}/users/{user_id}/sessions/{session_id}/artifacts/{artifact_name}` to load binary files or documents generated by the agent. + +### 2. Map Payload Schemas +The ADK API uses a specific JSON schema for requests and responses. You should thoroughly understand the schema and record your findings in `AGENT_INTERFACE_NOTES.md`. Here are the key fields: + +* **Run Request (`RunAgentRequest`):** + ```json + { + "app_name": "app", + "user_id": "default_user", + "session_id": "session-uuid", + "new_message": { + "role": "user", + "parts": [{ "text": "Research quantum computing." }] + }, + "streaming": true + } + ``` +* **Response Event:** Each SSE line starting with `data: ` contains an ADK `Event` JSON object. Map out these key fields in Dart: + * `id` (String): Unique identifier of the event. + * `author` (String): The name of the agent or "user" that generated the event. Useful for progress tracking. + * `content` (Object): Contains `parts`, which can hold `text` snippets, `functionCall` parameters (e.g., to `google_search`), `functionResponse` results, or `codeExecutionResult`. + * `actions` (Object): Holds metadata like `stateDelta` (map of state updates) and `artifactDelta` (map of updated files). + +### 3. Understand State, Artifact, & HITL/Resumability Management +The ADK provides global mechanisms for state persistence, file artifact delivery, and execution resumption. You must analyze the agent codebase to extract these specific implementations: + +* **State Keys Schema:** Map each discovered state key to its data structure. Inspect Python helper functions, dataclasses, or Pydantic models to identify if a key stores a `String`, a list of goals, or a complex nested `Map` (e.g., source mapping). +* **Artifact Creation:** Identify if the agent saves file artifacts to GCS or disk using `tool_context.save_artifact("name", part)`. Note their filenames, mime-types, and namespaces (e.g., `user:` vs session-scoped). +* **Human-in-the-Loop (HITL) & Resumability:** Locate exactly how the agent pauses and handles user approval gates: + * *Conversational Gate:* The agent stops execution, asks a question in text chat, and waits for a specific confirmation phrase (e.g., "looks good", "run it") to continue. + * *Formal API Gate:* The agent uses the `request_input` tool, calls `tool_context.request_confirmation(hint)`, or utilizes `require_confirmation` on a `FunctionTool`. + * *Resume Protocol:* If a formal gate is used, document how the client resumes the run (e.g., calling `/run_sse` with the corresponding `invocation_id` or `function_call_event_id` and the user's approval input). + +### 4. Record your Findings +* Add new sections to `AGENT_INTERFACE_NOTES.md` to record what you have learned: + 4. **API Endpoints and Routes**: List the endpoints exposed by the agent, what URL routes are available and what parameters and uses they have. + 5. **Payload Schemas**: List the data schemas used to send messages between the agent and client. Make sure to note which are used for requests vs responses. Record the schemas as JSON. + 6. **State and Artifact Management**: List the state variables and artifacts that the agent uses, and how they are managed. + 7. **Human-in-the-Loop (HITL) & Resumability**: Detail if and where the agent halts for user input or approval. Explain how the client should handle these gates (e.g., via chat text response or by calling a resume endpoint with specific invocation metadata). diff --git a/flutter-frontend-for-adk/references/frontend_architecture.md b/flutter-frontend-for-adk/references/frontend_architecture.md new file mode 100644 index 0000000..7520bc0 --- /dev/null +++ b/flutter-frontend-for-adk/references/frontend_architecture.md @@ -0,0 +1,211 @@ +# Frontend Architecture Reference + +This document directs the agent or developer to define the technical implementation, data model schemas, and folder structures of the frontend, and record them in a root-level `FRONTEND_ARCHITECTURE_NOTES.md` file. + +--- + +## Prerequisites + +> [!IMPORTANT] +> Before defining the frontend architecture, the developer or coding agent **must** verify that both `AGENT_INTERFACE_NOTES.md` and `FRONTEND_USAGE_NOTES.md` exist in the root of the workspace. If either file is missing, **stop immediately** and run Phase 1 (Discovery) and Phase 2 (Usage & Behavior) first. +> +> Refer to these files throughout: +> - Use `AGENT_INTERFACE_NOTES.md` to identify custom state keys, sub-agent execution pathways, and payload JSON models. +> - Use `FRONTEND_USAGE_NOTES.md` to map user features and UI screens to notifier capabilities and concrete widget files. + +--- + +## Instructions for Creating FRONTEND_ARCHITECTURE_NOTES.md + +You must analyze the agent interface (documented in `AGENT_INTERFACE_NOTES.md`) and the frontend behavior (documented in `FRONTEND_USAGE_NOTES.md`) and compile a technical architecture specification called `FRONTEND_ARCHITECTURE_NOTES.md` in the root of the workspace. + +This document must conform to the opinionated architectural constraints defined below, but adapt them to the specific backend agent by providing concrete Dart classes, file names, API payloads, and state properties. + +Create `FRONTEND_ARCHITECTURE_NOTES.md` containing the following sections: + +### 1. Architectural Patterns & Core Guidelines +Document the target architectural patterns, which must follow: +* **Layered separation of concerns:** + * *UI Layer:* Pure Flutter widgets observing state. + * *State Layer:* Single central `ChangeNotifier` managing connection, history lists, stream events, and active states. + * *Service Layer:* Wraps standard cross-platform `package:http` API calls (avoiding native `dart:io` or platform-specific web libraries) to manage connection lifecycles and SSE chunk processing. + * *Model Layer:* Plain Dart classes using manual JSON serialization. +* **SSE streaming parser:** Use a custom connection stream reading raw line-by-line chunks from the HTTP client streamed response. +* **Approved Packages:** List approved packages (`provider`, `flutter_markdown`, `url_launcher`, and `http`). Check with the user before suggesting any additional third-party dependencies. + +### 2. Concrete Directory Scaffold +Create a listing of all specific files and folder structures to be scaffolded under `lib/` matching the target agent's workflow. Give exact filenames instead of placeholders: +* `lib/main.dart` - Entrypoint, theme, and provider initialization. +* `lib/config.dart` - Config values containing the backend API host/port. +* `lib/models/` - List specific model files (e.g. `session.dart`, `event.dart`, and custom models derived from the agent's state structure, such as `research_goal.dart`, `source_citation.dart`). +* `lib/services/` - Specific service files (e.g. `adk_api_service.dart`). +* `lib/providers/` - Specific state notifier files (e.g. `agent_provider.dart`). +* `lib/ui/` - Specific screens and widget files matching the layout specification. + +### 3. Concrete Model Serialization Blueprints +Provide the exact Dart class declarations and manual JSON deserialization factories (`fromJson` / `toJson`) for: +* **Generic Envelope Models:** Standard ADK `Session`, `Event`, and `EventActions` envelopes. +* **Agent-Specific Custom Models:** Inspect `AGENT_INTERFACE_NOTES.md` Section 6 (State & Artifact Management) and map **every** state key listed there to an explicit typed model property in the `Session` model. For example, if the agent uses a `sources` state dictionary to track research sources to cite, define a typed `SourceCitation` class to match. Detail exactly how the `Session.fromJson` factory extracts and deserializes all of these structures. + +### 4. ChangeNotifier State & Action Map +Specify the exact instance variables and methods to be defined in the central `ChangeNotifier` to support the user experience described in `FRONTEND_USAGE_NOTES.md`: +* **State Fields:** List variable types and names (e.g. `List sessions`, `Session? activeSession`, `bool isExecuting`, `String? errorMessage`, etc.). +* **Action Methods:** List specific method names and signatures (e.g. `Future fetchSessionHistory()`, `Future deleteSession(String id)`, `Stream startAgentRun(String prompt)`, `void handleApprovalFeedback(String message)`). + +### 5. Stream Connection & Error Handling Logic +Detail how the service and state provider coordinate during streaming: +* Outline the SSE chunk reading process (decoding byte chunks, splitting lines, yielding decoded data frames). +* Specify how connection interruptions or server errors (e.g. non-200 responses) are propagated to the state provider to set `errorMessage` and transition state safely. + +--- + +## Architectural Guidelines Reference + +To assist you in formulating the concrete blueprints inside `FRONTEND_ARCHITECTURE_NOTES.md`, refer to the following standard code patterns: + +### A. Raw package:http SSE Parser Pattern +The client uses `http.Client` from `package:http` to stream and parse raw SSE chunks: +```dart +import 'dart:convert'; +import 'package:http/http.dart' as http; + +// Core connection logic: +final client = http.Client(); +final request = http.Request('POST', Uri.parse('$baseUrl/run_sse')); +request.headers['Content-Type'] = 'application/json'; +request.headers['Accept'] = 'text/event-stream'; +request.body = jsonEncode(payload); + +final streamedResponse = await client.send(request); +await for (final line in streamedResponse.stream + .transform(utf8.decoder) + .transform(const LineSplitter())) { + final trimmed = line.trim(); + if (trimmed.startsWith('data:')) { + final dataString = trimmed.substring(5).trim(); + // Parse JSON dataString into concrete Event models + } +} +``` + +### B. Standard Session and Event Model Envelopes +ADK serialization payload structures use camelCase properties: +```dart +class Event { + final String id; + final String? invocationId; + final String author; + final String? contentText; + final EventActions actions; + final bool partial; + final bool turnComplete; + + Event({ + required this.id, + this.invocationId, + required this.author, + this.contentText, + required this.actions, + required this.partial, + required this.turnComplete, + }); + + factory Event.fromJson(Map json) { + String? extractedText; + final content = json['content']; + if (content != null && content['parts'] != null) { + final List parts = content['parts']; + if (parts.isNotEmpty && parts.first['text'] != null) { + extractedText = parts.first['text'] as String; + } + } + return Event( + id: json['id'] as String, + invocationId: json['invocationId'] as String?, + author: json['author'] as String? ?? 'system', + contentText: extractedText, + actions: EventActions.fromJson(json['actions'] as Map? ?? {}), + partial: json['partial'] as bool? ?? false, + turnComplete: json['turnComplete'] as bool? ?? false, + ); + } +} + +class EventActions { + final Map stateDelta; + final bool endOfAgent; + + EventActions({ + required this.stateDelta, + required this.endOfAgent, + }); + + factory EventActions.fromJson(Map json) { + return EventActions( + stateDelta: json['stateDelta'] as Map? ?? {}, + endOfAgent: json['endOfAgent'] as bool? ?? false, + ); + } +} +``` + +### C. Standard REST Endpoints Pattern +The client uses standard `package:http` methods for pings, lists, creates, and deletes: +```dart +import 'dart:convert'; +import 'package:http/http.dart' as http; + +class AdkApiService { + final String baseUrl; + final http.Client _client = http.Client(); + + AdkApiService({required this.baseUrl}); + + /// GET /health + Future pingServer() async { + try { + final response = await _client.get(Uri.parse('$baseUrl/health')); + return response.statusCode == 200; + } catch (_) { + return false; + } + } + + /// GET /apps/app/users/default_user/sessions + Future> getSessions() async { + final response = await _client.get( + Uri.parse('$baseUrl/apps/app/users/default_user/sessions'), + ); + if (response.statusCode != 200) { + throw Exception('Failed to fetch sessions: ${response.statusCode}'); + } + final List decodedList = jsonDecode(response.body); + return decodedList + .map((item) => Session.fromJson(item as Map)) + .toList(); + } + + /// POST /apps/app/users/default_user/sessions + Future createSession() async { + final response = await _client.post( + Uri.parse('$baseUrl/apps/app/users/default_user/sessions'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode({}), // Send empty payload + ); + if (response.statusCode != 200) { + throw Exception('Failed to create session: ${response.statusCode}'); + } + return Session.fromJson(jsonDecode(response.body) as Map); + } + + /// DELETE /apps/app/users/default_user/sessions/{session_id} + Future deleteSession(String sessionId) async { + final response = await _client.delete( + Uri.parse('$baseUrl/apps/app/users/default_user/sessions/$sessionId'), + ); + if (response.statusCode != 200) { + throw Exception('Failed to delete session: ${response.statusCode}'); + } + } +} +``` diff --git a/flutter-frontend-for-adk/references/frontend_best_practices.md b/flutter-frontend-for-adk/references/frontend_best_practices.md new file mode 100644 index 0000000..bceb49e --- /dev/null +++ b/flutter-frontend-for-adk/references/frontend_best_practices.md @@ -0,0 +1,391 @@ +# Frontend Implementation Best Practices + +This document provides concrete code patterns and best practices for assembling the Flutter agent client. These guidelines address common implementation challenges, security configuration, typography, and styling. + +--- + +## 1. macOS and iOS Network Access & Security + +### macOS Sandbox Outbound Connections +When building and testing native macOS Flutter applications, the app runs within a secure sandbox environment by default. If the app needs to fetch assets (like Google Fonts) or communicate with local or remote APIs, it must be granted explicit outbound network permissions. + +#### Implementation Pattern +Locate the entitlements files inside the macOS runner directory: +* `frontend/macos/Runner/DebugProfile.entitlements` +* `frontend/macos/Runner/Release.entitlements` + +Ensure that the key `com.apple.security.network.client` is defined and set to `true` within the `` block: + +```xml + + + + + com.apple.security.app-sandbox + + com.apple.security.network.client + + + +``` + +### iOS App Transport Security (ATS) +Unlike macOS, iOS does not restrict outbound client network calls via sandbox entitlements files. However, Apple's **App Transport Security (ATS)** blocks insecure HTTP connections by default. If the application needs to make plaintext HTTP calls during development (e.g., connecting to a local backend agent server running on `http://127.0.0.1:8000` or `http://localhost:8000`), an exception must be configured. + +#### Implementation Pattern +Locate the `Info.plist` file inside the iOS runner directory: +* `frontend/ios/Runner/Info.plist` + +Add the `NSAppTransportSecurity` dictionary with `NSAllowsLocalNetworking` set to `true` to allow local loopback and local network HTTP connections: + +```xml +NSAppTransportSecurity + + NSAllowsLocalNetworking + + +``` + +--- + +## 2. Rich Markdown Rendering + +Agent response logs contain nested formatting, bold titles, lists, and code blocks. Plain text widgets are insufficient for displaying this nicely. Use `flutter_markdown` with customized typography rules that leverage the Outfit/Inter font system to maintain a premium feel. + +> [!IMPORTANT] +> **Stream-Safe Markdown Formatting:** Make sure that any text field or streaming response that could include markdown formatting is properly formatted and "pretty printed" in real time using `MarkdownBody` instead of standard `Text` widgets, even while the message is incomplete and streaming. + +### Implementation Pattern +Use `MarkdownBody` for rendering single message blocks and wrap it in a customized `MarkdownStyleSheet`: + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_markdown/flutter_markdown.dart'; +import 'package:google_fonts/google_fonts.dart'; + +Widget buildMarkdownMessage(BuildContext context, String text, Color textColor) { + return MarkdownBody( + data: text, + selectable: true, + styleSheet: MarkdownStyleSheet.fromTheme(Theme.of(context)).copyWith( + p: GoogleFonts.inter( + fontSize: 14, + height: 1.45, + color: textColor, + ), + strong: GoogleFonts.inter( + fontWeight: FontWeight.bold, + color: textColor, + ), + listBullet: GoogleFonts.inter( + fontSize: 14, + color: textColor, + ), + ), + ); +} +``` + +--- + +## 3. Dart Import Ordering & Formatting + +To ensure code cleanliness, maintain structure, and comply with standard style rules, imports must be structured according to the `directives_ordering` rule. + +### 1. Enable Linter Rule +Ensure that the `directives_ordering` rule is enabled in the project's [analysis_options.yaml](file:///Users/redbrogdon/source/flutter-adk/frontend/analysis_options.yaml): + +```yaml +linter: + rules: + directives_ordering: true +``` + +### 2. Automated Import Organizing & Formatting +Instead of manually ordering imports, use the Dart SDK's built-in tools to automate this process. Run the following command inside the `frontend/` directory to automatically sort all imports and resolve auto-fixable lint issues: + +```bash +dart fix --apply +``` + +To format all files according to standard style guidelines, run: + +```bash +dart format . +``` + +--- + +## 4. Sealed Classes & Exhaustive Pattern Matching for Lists + +Lists of messages or UI items from an agent run should be represented using Dart's `sealed` classes. Sealed classes restrict the inheritance hierarchy to the current library, enabling the analyzer to verify that `switch` statements and expressions are exhaustive (handling all possible types without needing a fallback/default clause). + +### Implementation Pattern + +1. **Define the Sealed Model Hierarchy**: + Define a sealed base class representing conversation items, and declare concrete subclasses for each specific data type (e.g., text, tool output, system event, surface widgets): + + ```dart + import 'package:flutter/widgets.dart'; + + sealed class ConversationItem {} + + class TextItem extends ConversationItem { + final String sender; + final String text; + + TextItem({required this.sender, required this.text}); + } + + class ImageItem extends ConversationItem { + final String url; + + ImageItem({required this.url}); + } + ``` + +2. **Render Using Exhaustive Switch Expressions**: + When building lists of items in a `ListView.builder`, map the items using a switch expression. This allows the compiler to enforce compile-time safety; if a new subclass of `ConversationItem` is added later, the compiler will fail to build until it is handled. + + ```dart + Widget buildConversationList(List items) { + return ListView.builder( + itemCount: items.length, + itemBuilder: (context, index) { + final item = items[index]; + + return switch (item) { + TextItem(sender: final sender, text: final text) => + BuildTextMessageBubble(sender: sender, text: text), + ImageItem(url: final url) => + BuildImageMessageBubble(url: url), + }; + }, + ); + } + ``` + +--- + +## 5. Scroll Position Anchoring (Guarding Viewport Yanking) + +When rendering live message feeds, logs, or agent streaming output, the chat window should auto-scroll to the bottom only if the user is already actively looking at the bottom. If the user scrolls up manually to review history, incoming data must not yank their viewport back to the bottom. + +### Implementation Pattern + +1. **Calculate Scroll Position**: + Track if the scroll position is currently at or near the bottom before laying out new items. Use a small threshold (such as `40` pixels) to account for layout variations. +2. **Conditional Animation**: + Trigger the scroll-to-bottom callback only if the active session changed (which warrants a complete view reset) or if the user is already at the bottom and new content is added. + +--- + +## 6. Timer Lifecycle and Resource Cleanup + +If the frontend performs periodic background tasks (such as polling a health endpoint, status checks, or refreshing data), these timers must be managed safely to prevent memory leaks and exceptions when widgets are unmounted from the tree. + +### Implementation Pattern + +1. **Keep a Timer Handle**: + Store the active `Timer` reference inside the state. +2. **Initialize in `initState`**: + Start the periodic loop during the widget initialization phase. +3. **Cancel in `dispose`**: + Cancel the timer in the `dispose` method. Always check if the state is still `mounted` before executing calls from background asynchronous triggers. + +```dart +import 'dart:async'; +import 'package:flutter/material.dart'; + +class StatusMonitor extends StatefulWidget { + const StatusMonitor({super.key}); + + @override + State createState() => _StatusMonitorState(); +} + +class _StatusMonitorState extends State { + Timer? _statusTimer; + + @override + void initState() { + super.initState(); + // Periodically run background check + _statusTimer = Timer.periodic(const Duration(seconds: 10), (timer) { + if (mounted) { + _runHealthCheck(); + } + }); + } + + @override + void dispose() { + // Prevent memory leak by canceling active timers + _statusTimer?.cancel(); + super.dispose(); + } + + void _runHealthCheck() { + // Perform API call + } + + @override + Widget build(BuildContext context) { + return const SizedBox.shrink(); + } +} +``` + +--- + +## 7. Web & Cross-Platform Compatibility (Avoiding `dart:io` Pitfalls) + +To ensure the client runs flawlessly on all target platforms (especially Flutter Web / Chrome), code must be structured to avoid platform-specific libraries that throw runtime exceptions in browsers. + +### Avoid `dart:io` Imports and APIs +* **HttpClient**: Do not import or use `HttpClient` or `Platform` from `dart:io`. Web browsers do not support these native socket-based operations, causing immediate runtime crashes like `Unsupported operation: Platform._version` or `Unsupported operation: HttpClient`. +* **Cross-Platform Alternatives**: Always use standard cross-platform packages: + * Use `package:http` (e.g., `http.Client`) for network operations and SSE streams. + * Use `package:url_launcher` for opening links. + * Use `kIsWeb` from `package:flutter/foundation.dart` for checking if running on Web. + +### Safe Platform Checks +If platform-specific logic is required, check `kIsWeb` first. Never access properties of the `Platform` class from `dart:io` on web targets: + +```dart +import 'package:flutter/foundation.dart' show kIsWeb; +import 'dart:io' show Platform; // WARNING: Do not import/reference if building for web! + +bool get isWebOrDesktop { + if (kIsWeb) return true; + // This is safe because Platform is only accessed when kIsWeb is false + return Platform.isMacOS || Platform.isWindows || Platform.isLinux; +} +``` + +*Best Practice:* Whenever possible, use UI responsiveness thresholds (like `LayoutBuilder` width constraints) to adapt screens, rather than hardcoding platform checks. + +--- + +## 8. Server-Sent Events (SSE) Streaming vs. Completed Message Handling + +When listening to a Server-Sent Events (SSE) stream from the ADK server, the client receives both partial chunk events (representing incremental text generation) and final completed events. To prevent duplicate, fragmented, or partial message bubbles from cluttering the conversation history, follow these guidelines: + +### 1. Separate Stream States +* **Partial Event Chunk (`event.partial == true`):** The text in these events represents active streaming data. Do **NOT** add these events to the permanent session event history list. Instead, accumulate the incoming characters inside temporary state variables (e.g., `activeStreamingResponse` and `activeStreamingAuthor`) in your state manager. +* **Complete Event Chunk (`event.partial == false`):** Once a final event chunk is received, append it to the permanent session event history list and clear the temporary streaming state accumulators. + +### 2. Implementation Pattern +Inside the stream receiver block in your state provider, handle the parsed events like so: + +```dart +await for (final event in eventStream) { + if (event.partial) { + // Accumulate streaming text in temporary state variables + _activeStreamingAuthor = event.author; + _activeStreamingResponse = (_activeStreamingResponse ?? '') + (event.contentText ?? ''); + } else { + // Stream chunk finished: Clear accumulator + _activeStreamingResponse = null; + _activeStreamingAuthor = null; + + // Append the completed event to conversation history list + final updatedEvents = List.from(_activeSession!.events)..add(event); + _activeSession = _activeSession!.copyWith(events: updatedEvents); + + // Apply state deltas if present + if (event.actions.stateDelta.isNotEmpty) { + _mergeStateDelta(event.actions.stateDelta); + } + } + notifyListeners(); +} +``` + +--- + +## 9. Casing Resiliency in JSON Deserialization (ADK Framework Discrepancy) + +The ADK framework-level endpoints return camelCase keys (such as `invocationId`, `turnComplete`, `functionCall`) in `/run_sse` chunked streams, but return snake_case keys (such as `invocation_id`, `turn_complete`, `function_call`) in REST history responses. The parser must check for both casings to support all ADK-based agents. + +### Implementation Pattern + +When parsing fields that differ between streams and history, check both keys: + +```dart +final fc = part['functionCall'] ?? part['function_call']; +final id = json['id'] ?? json['id']; +final invocationId = json['invocationId'] ?? json['invocation_id']; +``` + +--- + +## 10. Generic Tool Log Extraction (Built-in Grounding vs. Explicit Function Calls) + +To ensure that the client can display the tool execution history of any ADK-based agent (including routing decisions, planning operations, or web searches), the frontend should parse all tool invocations generically rather than hardcoding checks for specific tool names (e.g. `google_search`). + +### 1. Define a Generic `ToolCall` Model + +```dart +class ToolCall { + final String name; + final Map arguments; + + ToolCall({required this.name, required this.arguments}); + + String get displayString { + if (name == 'google_search') { + final q = arguments['query'] ?? + arguments['search_query'] ?? + arguments['searchQuery'] ?? + arguments['q'] ?? + ''; + return 'Google Search: "$q"'; + } + // Format other tools nicely, e.g. "plan_generator(request: ...)" + final argsStr = arguments.entries + .map((e) => '${e.key}: ${e.value}') + .join(', '); + return '$name($argsStr)'; + } +} +``` + +### 2. Implementation Pattern + +In the `Event` deserializer, extract both internal Gemini grounding queries and explicit function calls from parts into the `toolCalls` list: + +```dart +final List calls = []; + +// 1. Extract from grounding metadata (Gemini built-in search tool) +final gm = json['groundingMetadata'] ?? json['grounding_metadata']; +if (gm != null && gm is Map) { + final wsq = gm['webSearchQueries'] ?? gm['web_search_queries']; + if (wsq != null && wsq is List) { + for (final q in wsq) { + if (q != null) { + calls.add(ToolCall( + name: 'google_search', + arguments: {'query': q.toString()}, + )); + } + } + } +} + +// 2. Extract from parts (for explicit/custom tool calls) +for (final part in partsList) { + if (part is Map) { + final fc = part['functionCall'] ?? part['function_call']; + if (fc != null && fc is Map) { + final name = fc['name'] as String? ?? 'unknown_tool'; + final args = fc['args'] != null && fc['args'] is Map + ? Map.from(fc['args'] as Map) + : {}; + calls.add(ToolCall(name: name, arguments: args)); + } + } +} +``` + diff --git a/flutter-frontend-for-adk/references/frontend_design.md b/flutter-frontend-for-adk/references/frontend_design.md new file mode 100644 index 0000000..a3321a5 --- /dev/null +++ b/flutter-frontend-for-adk/references/frontend_design.md @@ -0,0 +1,131 @@ +# Frontend Design & Layout Reference + +This document directs the agent or developer to define the visual style, theming, responsive layouts, and interactive behaviors of the frontend, and record them in a root-level `FRONTEND_DESIGN_NOTES.md` file. + +--- + +## Prerequisites + +> [!IMPORTANT] +> Before defining the frontend design, the developer or coding agent **must** verify that `AGENT_INTERFACE_NOTES.md`, `FRONTEND_USAGE_NOTES.md`, and `FRONTEND_ARCHITECTURE_NOTES.md` exist in the root of the workspace. If any of these files are missing, **stop immediately** and complete the earlier phases first. +> +> Refer to these files throughout: +> - Use `AGENT_INTERFACE_NOTES.md` to understand the target agent itself (its core purpose, sub-agent catalog, and execution lifecycle) so that you can design custom visual indicators and timeline transitions that accurately reflect the running backend workflow. +> - Use `FRONTEND_USAGE_NOTES.md` to identify the required screens, features, and conversational sequences that need design specs. +> - Use `FRONTEND_ARCHITECTURE_NOTES.md` to align screen designs and widget layout divisions with the technical file structure (e.g. matching `dashboard_screen.dart` and custom widgets). + +--- + +## Instructions for Creating FRONTEND_DESIGN_NOTES.md + +### Step 1: Collect User Design Preferences +Before writing the design notes, the coding agent **must** present a set of options to the user in the chat and ask for their preferences. Do not assume or decide on these values without user input. Ask the user for their preferences on these topics: + +1. **Visual Theme & Tone** +2. **Primary Accent Color** +3. **Typography & Font Choices** +4. **Animations & Motion Style** + +Suggest some pre-generated options for each one based on the notes in `FRONTEND_USAGE_NOTES.md`, but allow the user to specify something else. + +--- + +### Step 2: Analyze the Agent's Screens & Adapt Layouts +Read `FRONTEND_USAGE_NOTES.md` Section 3 (Screen Specifications) to identify the specific screens, panels, drawers, or views that this specific agent requires. +Do not assume a standard 3-column split view; adapt your visual layout to the specific screens defined by the user's agent. + +--- + +### Step 3: Write FRONTEND_DESIGN_NOTES.md +Create `FRONTEND_DESIGN_NOTES.md` in the root of the project containing the following sections, using the user's responses from Step 1 and the screens analyzed in Step 2: + +#### 1. Global Theme & Visual Style +Specify the exact theme configurations for the application: +* **Color Palette:** Define the HEX values and Flutter color codes (`0xFF...`) for: + * *App Background:* Main scaffold background canvas. + * *Surface Background:* Lighter background for cards, sidebars, and input overlays. + * *Accent Primary:* Vibrant highlight color for primary actions, sliders, or active progress steps (based on the user's accent preference). + * *Accent Glow/Secondary:* Auxiliary highlight color for alerts or success indicators. + * *Text Primary:* Highly legible font color for headings, text inputs, and outputs. + * *Text Secondary:* Muted font color for helper labels and timestamp metadata. + * *Borders / Dividers:* Subtle boundary lines separating layout panes. +* **Typography Hierarchy:** Outline the chosen font families, font sizes, weights, and line-heights (optimized for readability based on the user's font preferences). +* **Visual Enhancements:** Shadow parameters (`BoxShadow` values), border radii (typically `12px` for modern cards, `8px` for inputs), and overlay opacities. +* **Accessibility Design:** Define guidelines for: + * Supporting system text-scaling preferences (preventing text clipping or overflow). + * Ensuring AA/AAA contrast ratios for text on active surfaces. + * Minimum touch/tap target sizes (minimum `48x48` logical pixels for mobile/tablet platforms). + +#### 2. Screen Specifications & Route Navigation +Define the routes, navigation patterns, and panel layouts for the specific screens derived in Step 2: +* **Routing Scheme:** Specify route paths and names (e.g., `/` for Dashboard, `/settings` for settings). +* **Screen Layout Structure:** Explain how the screen real estate is shared among the agent-specific panels on widescreen/desktop layouts. + +#### 3. Screen Breakpoints & Adaptive Layouts +Detail exactly how the app reflows the agent-specific panels across different screen widths: +* **Breakpoint Definitions:** + * *Desktop / Widescreen:* Width `>= 1024px` (All major panels visible side-by-side). + * *Tablet / Medium:* Width `< 1024px` and `>= 600px` (Sidebar collapses into a drawer, panels share remaining split space). + * *Mobile:* Width `< 600px` (Single column; reflows split panels into tabbed navigation pages or bottom sheets). +* **Platform Adaptations:** Design adjustments for target platforms (e.g., scrollbars and mouse-hover states for Desktop/Web, safe area margins and touch gestures for Mobile/iOS/Android). + +#### 4. Interactive Components & Micro-animations +Define states and animations matching the user's motion preference (Step 1) for the custom widgets: +* **Active Processing Indicators:** Pulse animations or loading spinners representing running agent states. +* **Hover & Active Highlights:** Visual feedback (scale, color shifts, opacity changes) when hovering or clicking buttons, cards, and links. +* **Content Transitions:** Smooth opacity fades (`FadeIn`) or sliding animations (`SlideTransition`) for streaming content blocks, plan checklists, or new message feeds. + +--- + +## Design Reference Guidelines + +Use the following code definitions as standard implementation patterns when writing `FRONTEND_DESIGN_NOTES.md`: + +### A. Dark Mode Material ThemeData Definition +```dart +final ThemeData darkTheme = ThemeData( + brightness: Brightness.dark, + scaffoldBackgroundColor: const Color(0xFF0B0F19), + cardColor: const Color(0xFF131926), + primaryColor: const Color(0xFF6366F1), + dividerColor: const Color(0xFF1E293B), + textTheme: const TextTheme( + headlineLarge: TextStyle(fontSize: 28, fontWeight: FontWeight.bold, color: Color(0xFFF8FAFC)), + bodyMedium: TextStyle(fontSize: 15, height: 1.5, color: Color(0xFF94A3B8)), + ), +); +``` + +### B. Responsive Layout Implementation Pattern +Use `LayoutBuilder` to dynamically reflow the UI columns based on screen width: +```dart +Widget build(BuildContext context) { + return Scaffold( + body: LayoutBuilder( + builder: (context, constraints) { + if (constraints.maxWidth >= 1024) { + // Desktop: Show sidebar and panels side-by-side + return Row( + children: [ + const SidebarWidget(width: 260), + Expanded(child: ChatPanelWidget()), + Expanded(child: WorkspacePanelWidget()), + ], + ); + } else if (constraints.maxWidth >= 600) { + // Tablet: Collapsible sidebar, side-by-side panels + return Row( + children: [ + Expanded(child: ChatPanelWidget()), + Expanded(child: WorkspacePanelWidget()), + ], + ); + } else { + // Mobile: Single panel with tab navigation + return MobileTabbedView(); + } + }, + ), + ); +} +``` diff --git a/flutter-frontend-for-adk/references/frontend_usage.md b/flutter-frontend-for-adk/references/frontend_usage.md new file mode 100644 index 0000000..bd58d0e --- /dev/null +++ b/flutter-frontend-for-adk/references/frontend_usage.md @@ -0,0 +1,55 @@ +# Frontend Usage & Behavior Reference + +This document directs the agent or developer to define the functional, behavioral, and feature requirements of the frontend and record them in a root-level `FRONTEND_USAGE_NOTES.md` file. + +--- + +## Prerequisites + +> [!IMPORTANT] +> Before defining the frontend usage and behavior, the developer or coding agent **must** verify that `AGENT_INTERFACE_NOTES.md` exists in the root of the workspace. If this file is missing, **stop immediately** and run Phase 1 (Workspace & Agent Discovery) first to analyze the agent and create it. + +## Instructions for Creating FRONTEND_USAGE_NOTES.md + +You must analyze the agent requirements (documented in `AGENT_INTERFACE_NOTES.md` and `README.md`) and compile a functional specification called `FRONTEND_USAGE_NOTES.md` in the root of the workspace. + +This document must focus strictly on **behavior, screens, and user features**, leaving out internal architecture patterns (like Riverpod or Clean Architecture) and visual design details (like specific color tokens, font files, and padding values). + +Create `FRONTEND_USAGE_NOTES.md` containing the following sections: + +### 1. Introduction & Target Platforms +* **Overview:** Briefly describe what the application does from a user's point of view (e.g., interactive chat assistant, structured report builder, data analyzer). +* **Target Platforms:** Detail which platforms the frontend supports (e.g. Flutter Web, macOS Desktop, iOS, Android). You should ask the user which ones they want the app to build for. + +### 2. User Experience Flow +Describe the high-level step-by-step lifecycle of a user session: +1. **Onboarding:** How the user starts (e.g., entering a topic, prompt, or file upload). +2. **Interactive Gates (if applicable):** How the user reviews and edits intermediate state (e.g. reviewing plans, configuring parameters, or providing approval feedback). +3. **Execution Observation:** What the user sees while the autonomous agent or workflow loop is executing (e.g. progress updates, sub-agent transitions, or active tool invocations). +4. **Output Review:** How the user interacts with the final deliverables (e.g. browsing reports, opening references/sources, exporting files, or viewing charts). + +### 3. Screen Specifications +Detail the functionality and interactive controls of each view in the application: +* **Dashboard Layout:** + * Sidebar containing session lists (creating new sessions, switching between runs, and deleting sessions). + * Connection status indicators (backend API online/offline). +* **Interaction / Chat View:** + * Standard text input and send action. + * Message log showing user vs agent/system messages. + * *Human-in-the-Loop (HITL) Controls:* Custom interactive widgets rendered when the agent requests confirmation or input (e.g. structured checklists, parameter sliders, or yes/no approval prompts). +* **Workspace / Execution View:** + * *Progress Timeline:* A progress indicator displaying the execution status, active sub-agent name (drawn from the event's `author` tag), or current node in the workflow graph. + * *Tool Invocation Logs:* A live activity log showing active tool executions (e.g., web searches, database queries, code interpreter sandboxes) including inputs and status. + * *Rich Output Viewer:* Renderers for the completed deliverables (e.g. Markdown text, comparison tables, code syntax highlighting, or file download buttons). + * *References / Citation Drawer:* Displays source documentation, grounding links, or database record cards when the user clicks inline citations/references in the output. + +### 4. User-Facing Feature Checklist +Create a prioritized list (e.g., MUST HAVE, SHOULD HAVE, COULD HAVE) of functional features: +* Multi-turn chat conversation. +* Interactive state review and approval gate controls. +* Server-Sent Events (SSE) streaming parse (word-by-word message rendering). +* Live execution progress and sub-agent timeline tracking. +* Real-time tool execution logging. +* Markdown or rich output rendering (supporting tables, links, and code). +* Clickable citation links with source detail views. +* Session history storage (listing, switching, and deleting runs). diff --git a/genkit_flutter_agentic_app/genkit_backend/package-lock.json b/genkit_flutter_agentic_app/genkit_backend/package-lock.json index 2aa7d0a..4ddf8a3 100644 --- a/genkit_flutter_agentic_app/genkit_backend/package-lock.json +++ b/genkit_flutter_agentic_app/genkit_backend/package-lock.json @@ -1132,18 +1132,36 @@ } }, "node_modules/@grpc/grpc-js": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.13.2.tgz", - "integrity": "sha512-nnR5nmL6lxF8YBqb6gWvEgLdLh/Fn+kvAdX5hUOnt48sNSb0riz/93ASd2E5gvanPA41X6Yp25bIfGRp1SMb2g==", + "version": "1.14.4", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.4.tgz", + "integrity": "sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ==", "license": "Apache-2.0", "dependencies": { - "@grpc/proto-loader": "^0.7.13", + "@grpc/proto-loader": "^0.8.0", "@js-sdsl/ordered-map": "^4.4.2" }, "engines": { "node": ">=12.10.0" } }, + "node_modules/@grpc/grpc-js/node_modules/@grpc/proto-loader": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.8.1.tgz", + "integrity": "sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg==", + "license": "Apache-2.0", + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.5.5", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/@grpc/proto-loader": { "version": "0.7.13", "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.7.13.tgz", @@ -1163,9 +1181,9 @@ } }, "node_modules/@hono/node-server": { - "version": "1.19.9", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.9.tgz", - "integrity": "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==", + "version": "1.19.14", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", + "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", "dev": true, "license": "MIT", "engines": { @@ -1722,21 +1740,21 @@ } }, "node_modules/@modelcontextprotocol/sdk/node_modules/body-parser": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.1.tgz", - "integrity": "sha512-nfDwkulwiZYQIGwxdy0RUmowMhKcFVcYXUU7m4QlKYim1rUtg83xm2yjZ40QjDuc291AJjjeSc9b++AWHSgSHw==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", "dev": true, "license": "MIT", "dependencies": { "bytes": "^3.1.2", - "content-type": "^1.0.5", + "content-type": "^2.0.0", "debug": "^4.4.3", - "http-errors": "^2.0.0", - "iconv-lite": "^0.7.0", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", - "qs": "^6.14.0", - "raw-body": "^3.0.1", - "type-is": "^2.0.1" + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" }, "engines": { "node": ">=18" @@ -1746,6 +1764,20 @@ "url": "https://opencollective.com/express" } }, + "node_modules/@modelcontextprotocol/sdk/node_modules/body-parser/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/@modelcontextprotocol/sdk/node_modules/content-disposition": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", @@ -1886,9 +1918,9 @@ } }, "node_modules/@modelcontextprotocol/sdk/node_modules/iconv-lite": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.1.tgz", - "integrity": "sha512-2Tth85cXwGFHfvRgZWszZSvdo+0Xsqmw8k8ZwxScfcBneNUraK+dxRxRm24nszx80Y0TVio8kKLt5sLE7ZCLlw==", + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", "dev": true, "license": "MIT", "dependencies": { @@ -1979,22 +2011,6 @@ "node": ">= 0.6" } }, - "node_modules/@modelcontextprotocol/sdk/node_modules/qs": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", - "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/@modelcontextprotocol/sdk/node_modules/raw-body": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", @@ -2069,18 +2085,36 @@ } }, "node_modules/@modelcontextprotocol/sdk/node_modules/type-is": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", - "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", "dev": true, "license": "MIT", "dependencies": { - "content-type": "^1.0.5", + "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" }, "engines": { - "node": ">= 0.6" + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/@modelcontextprotocol/sdk/node_modules/zod": { @@ -2103,6 +2137,19 @@ "zod": "^3.25 || ^4" } }, + "node_modules/@nodable/entities": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.2.0.tgz", + "integrity": "sha512-9uGyhaQavEUMC8AIddIjau4NsnsXhou+j5sBAGojCM1oxmQpVKTWR/9JxABD6UAv12vpIms55fPZKFQEhG6uBg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nodable" + } + ], + "license": "MIT", + "optional": true + }, "node_modules/@opentelemetry/api": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", @@ -3059,9 +3106,9 @@ "license": "BSD-3-Clause" }, "node_modules/@protobufjs/codegen": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", - "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==", + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", "license": "BSD-3-Clause" }, "node_modules/@protobufjs/eventemitter": { @@ -3087,9 +3134,9 @@ "license": "BSD-3-Clause" }, "node_modules/@protobufjs/inquire": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", - "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.1.tgz", + "integrity": "sha512-mnzgDV26ueAvk7rsbt9L7bE0SuAoqyuys/sMMrmVcN5x9VsxpcG3rqAUSgDyLp0UZlmNfIbQ4fHfCtreVBk8Ew==", "license": "BSD-3-Clause" }, "node_modules/@protobufjs/path": { @@ -3105,9 +3152,9 @@ "license": "BSD-3-Clause" }, "node_modules/@protobufjs/utf8": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", - "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz", + "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==", "license": "BSD-3-Clause" }, "node_modules/@so-ric/colorspace": { @@ -3122,9 +3169,9 @@ } }, "node_modules/@tootallnate/once": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", - "integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.1.tgz", + "integrity": "sha512-HqmEUIGRJ5fSXchkVgR5F7qn48bDBzv0kWj/Kfu5e6uci4UlEeng4331LnBkWffb++Ei3FOVLxo8JJWMFBDMeQ==", "license": "MIT", "engines": { "node": ">= 10" @@ -3304,15 +3351,15 @@ } }, "node_modules/@types/request/node_modules/form-data": { - "version": "2.5.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.5.tgz", - "integrity": "sha512-jqdObeR2rxZZbPSGL+3VckHMYtu+f9//KXBsVny6JSX/pa38Fy+bGjuG8eW/H6USNQWhLi8Num++cU2yOCNz4A==", + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.6.tgz", + "integrity": "sha512-Ogz/E85h9tlfJzpI6TuFpGcHZFhLrb9Gw8wq9v40CxSCPnv7ahKr6Xgtkn0KYCDQJ8DNn5VoMO8EXr9V5PadyA==", "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", + "hasown": "^2.0.4", "mime-types": "^2.1.35", "safe-buffer": "^5.2.1" }, @@ -3545,6 +3592,19 @@ "node": ">= 8" } }, + "node_modules/anynum": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/anynum/-/anynum-1.0.1.tgz", + "integrity": "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "optional": true + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -3601,17 +3661,70 @@ "license": "MIT" }, "node_modules/axios": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", - "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.1.tgz", + "integrity": "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/axios/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/axios/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/axios/node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", "dev": true, "license": "MIT", "dependencies": { - "follow-redirects": "^1.15.6", - "form-data": "^4.0.4", - "proxy-from-env": "^1.1.0" + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" } }, + "node_modules/axios/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -3688,29 +3801,58 @@ } }, "node_modules/body-parser": { - "version": "1.20.3", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", - "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", + "version": "1.20.5", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", + "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", "license": "MIT", "dependencies": { - "bytes": "3.1.2", + "bytes": "~3.1.2", "content-type": "~1.0.5", "debug": "2.6.9", "depd": "2.0.0", - "destroy": "1.2.0", - "http-errors": "2.0.0", - "iconv-lite": "0.4.24", - "on-finished": "2.4.1", - "qs": "6.13.0", - "raw-body": "2.5.2", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.15.1", + "raw-body": "~2.5.3", "type-is": "~1.6.18", - "unpipe": "1.0.0" + "unpipe": "~1.0.0" }, "engines": { "node": ">= 0.8", "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/body-parser/node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/brace-expansion": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", @@ -4505,39 +4647,39 @@ } }, "node_modules/express": { - "version": "4.21.2", - "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", - "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", "license": "MIT", "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", - "body-parser": "1.20.3", - "content-disposition": "0.5.4", + "body-parser": "~1.20.5", + "content-disposition": "~0.5.4", "content-type": "~1.0.4", - "cookie": "0.7.1", - "cookie-signature": "1.0.6", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", "debug": "2.6.9", "depd": "2.0.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", - "finalhandler": "1.3.1", - "fresh": "0.5.2", - "http-errors": "2.0.0", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", "merge-descriptors": "1.0.3", "methods": "~1.1.2", - "on-finished": "2.4.1", + "on-finished": "~2.4.1", "parseurl": "~1.3.3", - "path-to-regexp": "0.1.12", + "path-to-regexp": "~0.1.12", "proxy-addr": "~2.0.7", - "qs": "6.13.0", + "qs": "~6.15.1", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", - "send": "0.19.0", - "serve-static": "1.16.2", + "send": "~0.19.0", + "serve-static": "~1.16.2", "setprototypeof": "1.2.0", - "statuses": "2.0.1", + "statuses": "~2.0.1", "type-is": "~1.6.18", "utils-merge": "1.0.1", "vary": "~1.1.2" @@ -4551,13 +4693,13 @@ } }, "node_modules/express-rate-limit": { - "version": "8.2.1", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz", - "integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==", + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz", + "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==", "dev": true, "license": "MIT", "dependencies": { - "ip-address": "10.0.1" + "ip-address": "^10.2.0" }, "engines": { "node": ">= 16" @@ -4638,9 +4780,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.6.tgz", - "integrity": "sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw==", + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz", + "integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==", "funding": [ { "type": "github", @@ -4654,9 +4796,9 @@ "license": "BSD-3-Clause" }, "node_modules/fast-xml-builder": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.4.tgz", - "integrity": "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz", + "integrity": "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==", "funding": [ { "type": "github", @@ -4666,13 +4808,14 @@ "license": "MIT", "optional": true, "dependencies": { - "path-expression-matcher": "^1.1.3" + "path-expression-matcher": "^1.5.0", + "xml-naming": "^0.1.0" } }, "node_modules/fast-xml-parser": { - "version": "5.5.7", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.7.tgz", - "integrity": "sha512-LteOsISQ2GEiDHZch6L9hB0+MLoYVLToR7xotrzU0opCICBkxOPgHAy1HxAvtxfJNXDJpgAsQN30mkrfpO2Prg==", + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.9.3.tgz", + "integrity": "sha512-brCNCeScma/kqa54J4PIDriSSSLssRkuYaUCpvHJulGc3HGI/xxKUCTDcYkAdqJsyb//ydpbxecjC3hB9+tb/g==", "funding": [ { "type": "github", @@ -4682,9 +4825,12 @@ "license": "MIT", "optional": true, "dependencies": { - "fast-xml-builder": "^1.1.4", - "path-expression-matcher": "^1.1.3", - "strnum": "^2.2.0" + "@nodable/entities": "^2.2.0", + "fast-xml-builder": "^1.2.0", + "is-unsafe": "^1.0.1", + "path-expression-matcher": "^1.5.0", + "strnum": "^2.4.1", + "xml-naming": "^0.1.0" }, "bin": { "fxparser": "src/cli/cli.js" @@ -4839,9 +4985,9 @@ "license": "MIT" }, "node_modules/follow-redirects": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", - "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", "dev": true, "funding": [ { @@ -4877,16 +5023,16 @@ } }, "node_modules/form-data": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", - "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" + "hasown": "^2.0.4", + "mime-types": "^2.1.35" }, "engines": { "node": ">= 6" @@ -5383,9 +5529,9 @@ } }, "node_modules/handlebars": { - "version": "4.7.8", - "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", - "integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==", + "version": "4.7.9", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz", + "integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==", "license": "MIT", "dependencies": { "minimist": "^1.2.5", @@ -5441,9 +5587,9 @@ } }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -5453,9 +5599,9 @@ } }, "node_modules/hono": { - "version": "4.12.7", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.7.tgz", - "integrity": "sha512-jq9l1DM0zVIvsm3lv9Nw9nlJnMNPOcAtsbsgiUhWcFzPE99Gvo6yRTlszSLLYacMeQ6quHD6hMfId8crVHvexw==", + "version": "4.12.27", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.27.tgz", + "integrity": "sha512-1yrb/+w6HWQJrUCLkJ2IF5jNIPvvFkblV5RNOYl6bV+OA6p9GLcMpHFFGTosSvHvcAUibuUukRqhlYI4z32C7Q==", "dev": true, "license": "MIT", "engines": { @@ -5658,9 +5804,9 @@ "license": "ISC" }, "node_modules/ip-address": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz", - "integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==", + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", "dev": true, "license": "MIT", "engines": { @@ -5815,6 +5961,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-unsafe": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-unsafe/-/is-unsafe-1.0.1.tgz", + "integrity": "sha512-CLK2+VdgERgD96EYm5lUQssZYlRg2tkZnbsxZoacmSiRxiFJ4Nk4SzjCl+Ur+v3kXIY9dTIdb3IH22y1mZ56LA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "optional": true + }, "node_modules/is-wsl": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-1.1.0.tgz", @@ -5859,10 +6018,20 @@ } }, "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -6434,9 +6603,9 @@ } }, "node_modules/node-forge": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.2.tgz", - "integrity": "sha512-6xKiQ+cph9KImrRh0VsjH2d8/GXA4FIMlgU4B757iI1ApvcyA9VlouP0yZJha01V+huImO+kKMU7ih+2+E14fw==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.4.0.tgz", + "integrity": "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==", "license": "(BSD-3-Clause OR GPL-2.0)", "optional": true, "engines": { @@ -6688,9 +6857,9 @@ "license": "MIT" }, "node_modules/path-expression-matcher": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.1.3.tgz", - "integrity": "sha512-qdVgY8KXmVdJZRSS1JdEPOKPdTiEK/pi0RkcT2sw1RhXxohdujUlJFPuS1TSkevZ9vzd3ZlL7ULl1MHGTApKzQ==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.6.1.tgz", + "integrity": "sha512-h7bxdzhHk8Knyc4Tj+jMaa7fEEoUJy7p1qtbVgkYg1Uhpe5Np5VuGXCRZnkZvU+Q42M1vStt0ifa3ueykRJPmQ==", "funding": [ { "type": "github", @@ -6823,22 +6992,22 @@ } }, "node_modules/protobufjs": { - "version": "7.4.0", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.4.0.tgz", - "integrity": "sha512-mRUWCc3KUU4w1jU8sGxICXH/gNS94DvI1gxqDvBzhj1JpcsimQkYiOJfwsPUykUI5ZaspFbSgmBLER8IrQ3tqw==", + "version": "7.5.8", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.8.tgz", + "integrity": "sha512-dvpCIeLPbXZS/Ete7yLaO7RenOdken2NHKykBXbsaGxZT0UTltcarBciw+A78SRQs9iMAAVpsYA+l8b1hTePIA==", "hasInstallScript": true, "license": "BSD-3-Clause", "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", - "@protobufjs/codegen": "^2.0.4", + "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", - "@protobufjs/inquire": "^1.1.0", + "@protobufjs/inquire": "^1.1.1", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", - "@protobufjs/utf8": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.0.0" }, @@ -6860,11 +7029,14 @@ } }, "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=10" + } }, "node_modules/ps-tree": { "version": "1.2.0", @@ -6903,12 +7075,13 @@ } }, "node_modules/qs": { - "version": "6.13.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", - "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", "license": "BSD-3-Clause", "dependencies": { - "side-channel": "^1.0.6" + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" }, "engines": { "node": ">=0.6" @@ -6927,20 +7100,49 @@ } }, "node_modules/raw-body": { - "version": "2.5.2", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", - "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", "license": "MIT", "dependencies": { - "bytes": "3.1.2", - "http-errors": "2.0.0", - "iconv-lite": "0.4.24", - "unpipe": "1.0.0" + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" }, "engines": { "node": ">= 0.8" } }, + "node_modules/raw-body/node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body/node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/readable-stream": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", @@ -7301,14 +7503,14 @@ "license": "BSD-2-Clause" }, "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" }, @@ -7320,13 +7522,13 @@ } }, "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" + "object-inspect": "^1.13.4" }, "engines": { "node": ">= 0.4" @@ -7517,9 +7719,9 @@ } }, "node_modules/strnum": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.1.tgz", - "integrity": "sha512-BwRvNd5/QoAtyW1na1y1LsJGQNvRlkde6Q/ipqqEaivoMdV+B1OMOTVdwR+N/cwVUcIt9PYyHmV8HyexCZSupg==", + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.4.1.tgz", + "integrity": "sha512-M9eUSMT2dCB2cTNPG7UYj6KuK7RJR2SN2+yCV/fTW3xzTCS6EaGZ5pSMgDIjB7r8zSfTGk+dvvn9rTjpVS9Mwg==", "funding": [ { "type": "github", @@ -7527,7 +7729,10 @@ } ], "license": "MIT", - "optional": true + "optional": true, + "dependencies": { + "anynum": "^1.0.1" + } }, "node_modules/stubs": { "version": "3.0.0", @@ -8075,6 +8280,22 @@ "node": ">=8" } }, + "node_modules/xml-naming": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz", + "integrity": "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "optional": true, + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", @@ -8092,9 +8313,9 @@ "optional": true }, "node_modules/yaml": { - "version": "2.8.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", - "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", "license": "ISC", "bin": { "yaml": "bin.mjs" diff --git a/genlatte/.gitignore b/genlatte/.gitignore new file mode 100644 index 0000000..e69de29 diff --git a/genlatte/firebase/.firebaserc b/genlatte/firebase/.firebaserc new file mode 100644 index 0000000..b05fe7e --- /dev/null +++ b/genlatte/firebase/.firebaserc @@ -0,0 +1,7 @@ +{ + "projects": { + "default": "gcdemos-26-int-dd-latteart" + }, + "targets": {}, + "etags": {} +} diff --git a/genlatte/firebase/.gitignore b/genlatte/firebase/.gitignore new file mode 100644 index 0000000..b17f631 --- /dev/null +++ b/genlatte/firebase/.gitignore @@ -0,0 +1,69 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +firebase-debug.log* +firebase-debug.*.log* + +# Firebase cache +.firebase/ + +# Firebase config + +# Uncomment this if you'd like others to create their own Firebase project. +# For a team working on the same Firebase project(s), it is recommended to leave +# it commented so all members can deploy to the same project(s) in .firebaserc. +# .firebaserc + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (http://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variables file +.env + +# dataconnect generated files +.dataconnect diff --git a/genlatte/firebase/CONTEXT.md b/genlatte/firebase/CONTEXT.md new file mode 100644 index 0000000..f66c93d --- /dev/null +++ b/genlatte/firebase/CONTEXT.md @@ -0,0 +1,46 @@ +# Firebase Infrastructure (`firebase/`) + +## Purpose +This directory contains the root configuration for the GenLatte Firebase project. It orchestrates the deployment and local execution of hosting, firestore rules, remote configuration, and cloud functions. + +## Detailed File Overviews + +- **`firebase.json`**: + - **Description**: The primary configuration file for the Firebase CLI. + - **Core Logic**: + - **Firestore**: Points to `firestore.rules` and `firestore.indexes.json`. + - **Functions**: Configures the `functions/` subdirectory as the source, specifies Node 24 runtime, and defines the `npm build` predeploy hook. + - **Hosting**: Configures the `public/` folder and defines a catch-all rewrite to `index.html` for single-page applications (SPAs). + - **Emulators**: Defines port mappings for local development (Auth: 9099, Firestore: 8080, Functions: 5001, etc.). + +- **`.firebaserc`**: + - **Description**: Maps project aliases (e.g., `default`, `staging`) to specific Firebase Project IDs. + +- **`firestore.rules`**: + - **Description**: Defines security and access policies for the Firestore database. + +- **`remoteconfig.json`**: + - **Description**: The default template for project-wide dynamic configuration values. + +## Subdirectories Overview + +- **`functions/`**: The core backend source code and Node.js environment (see [functions/CONTEXT.md](functions/CONTEXT.md)). + +## Dependencies/Relationships + +- **Firebase CLI**: Necessary for all interactions within this directory. +- **Google Cloud Console**: Used for managing the underlying Firestore and Cloud Run instances mapped here. + +## Usage/Exports + +### Project Initialization +To switch between project environments: +```bash +firebase use +``` + +### Local Development +To start the full emulator suite: +```bash +firebase emulators:start +``` diff --git a/genlatte/firebase/cors.json b/genlatte/firebase/cors.json new file mode 100644 index 0000000..bd9d0df --- /dev/null +++ b/genlatte/firebase/cors.json @@ -0,0 +1,7 @@ +[ + { + "origin": ["*"], + "method": ["GET"], + "maxAgeSeconds": 3600 + } +] \ No newline at end of file diff --git a/genlatte/firebase/create_users.js b/genlatte/firebase/create_users.js new file mode 100644 index 0000000..2b2841a --- /dev/null +++ b/genlatte/firebase/create_users.js @@ -0,0 +1,79 @@ +const admin = require('firebase-admin'); + +// Initialize Firebase Admin +admin.initializeApp(); + +const auth = admin.auth(); + +const branch = process.env.BRANCH_NAME; +const password = process.env.USER_PASSWORD; + +if (!branch) { + console.error('BRANCH_NAME environment variable is not set.'); + process.exit(1); +} + +if (!password) { + console.error('USER_PASSWORD environment variable is not set.'); + process.exit(1); +} + +const usersToCreate = [ + { email: `nohe+kiosk_${branch}@google.com`, claims: { kiosk: true } }, + { email: `nohe+barista_${branch}@google.com`, claims: { barista: true } }, + { email: `nohe+queue_${branch}@google.com`, claims: { queue: true } }, + { email: `nohe+recent_${branch}@google.com`, claims: { recent: true } }, + { email: `nohe+moderator_${branch}@google.com`, claims: { moderator: true } }, +]; + +async function seedUsers() { + for (const userData of usersToCreate) { + try { + let user; + try { + user = await auth.getUserByEmail(userData.email); + console.log(`User already exists: ${userData.email}`); + } catch (error) { + if (error.code === 'auth/user-not-found') { + console.log(`Creating user: ${userData.email}`); + user = await auth.createUser({ + email: userData.email, + password: password, + emailVerified: true, + }); + } else { + throw error; + } + } + + const currentClaims = user.customClaims || {}; + const newClaims = userData.claims || {}; + + const keys1 = Object.keys(currentClaims); + const keys2 = Object.keys(newClaims); + + let claimsMatch = keys1.length === keys2.length; + if (claimsMatch) { + for (const key of keys1) { + if (currentClaims[key] !== newClaims[key]) { + claimsMatch = false; + break; + } + } + } + + if (!claimsMatch) { + console.log(`Setting custom claims for ${userData.email}:`, userData.claims); + await auth.setCustomUserClaims(user.uid, userData.claims); + } else { + console.log(`Claims already up to date for ${userData.email}`); + } + } catch (error) { + console.error(`Error processing user ${userData.email}:`, error); + process.exit(1); + } + } + console.log('User seeding completed successfully.'); +} + +seedUsers(); diff --git a/genlatte/firebase/firebase.json b/genlatte/firebase/firebase.json new file mode 100644 index 0000000..6ac5f00 --- /dev/null +++ b/genlatte/firebase/firebase.json @@ -0,0 +1,66 @@ +{ + "firestore": { + "database": "DATABASE_ID_PLACEHOLDER", + "location": "nam5", + "rules": "firestore.rules", + "indexes": "firestore.indexes.json" + }, + "functions": [ + { + "source": "functions", + "codebase": "default", + "disallowLegacyRuntimeConfig": true, + "ignore": [ + "node_modules", + ".git", + "firebase-debug.log", + "firebase-debug.*.log", + "*.local" + ], + "predeploy": [ + "npm --prefix \"$RESOURCE_DIR\" run build" + ] + } + ], + "remoteconfig": { + "template": "remoteconfig.json" + }, + "hosting": { + "public": "public", + "ignore": [ + "firebase.json", + "**/.*", + "**/node_modules/**" + ], + "rewrites": [ + { + "source": "**", + "destination": "/index.html" + } + ] + }, + "emulators": { + "auth": { + "port": 9099 + }, + "functions": { + "port": 5001 + }, + "firestore": { + "port": 8080 + }, + "hosting": { + "port": 5000 + }, + "ui": { + "enabled": true + }, + "singleProjectMode": true, + "tasks": { + "port": 5003 + }, + "eventarc": { + "port": 9299 + } + } +} diff --git a/genlatte/firebase/firestore.indexes.json b/genlatte/firebase/firestore.indexes.json new file mode 100644 index 0000000..3e06d2f --- /dev/null +++ b/genlatte/firebase/firestore.indexes.json @@ -0,0 +1,33 @@ +{ + "indexes": [ + { + "collectionGroup": "latteOrderMetadata", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "status", + "order": "ASCENDING" + }, + { + "fieldPath": "orderSubmittedTime", + "order": "DESCENDING" + } + ] + }, + { + "collectionGroup": "latteOrderMetadata", + "queryScope": "COLLECTION", + "fields":[ + { + "fieldPath": "status", + "order": "ASCENDING" + }, + { + "fieldPath": "completionTime", + "order": "ASCENDING" + } + ] + } + ], + "fieldOverrides": [] +} \ No newline at end of file diff --git a/genlatte/firebase/firestore.rules b/genlatte/firebase/firestore.rules new file mode 100644 index 0000000..ff60296 --- /dev/null +++ b/genlatte/firebase/firestore.rules @@ -0,0 +1,99 @@ +rules_version='2' + +service cloud.firestore { + match /databases/{database}/documents { + function isValidSweetener(sweetener) { + return sweetener in get(/databases/$(database)/documents/latteOptions/sweeteners).data.values || sweetener == null; + } + + function isValidMilk(milk) { + return milk in get(/databases/$(database)/documents/latteOptions/milks).data.values || milk == null; + } + + function isKiosk() { + return request.auth != null && request.auth.token.kiosk == true; + } + + function isModerator() { + return request.auth != null && request.auth.token.moderator == true; + } + + function isQueue() { + return request.auth != null && request.auth.token.queue == true; + } + + function isRecent() { + return request.auth != null && request.auth.token.recent == true; + } + + function isBarista() { + return request.auth != null && request.auth.token.barista == true; + } + + function onlyHasStringName(data) { + return data.keys().hasOnly(['name', 'milk', 'sweetener', 'happyPlace', 'imageUrl']) + && data.name is string; + } + + function kioskAllowedModification(data) { + return data.keys().hasOnly(['name', 'milk', 'sweetener', 'happyPlace', 'imageUrl', 'orderNumber', 'expiresAt']) + && data.name is string && + (!('milk' in data) || isValidMilk(data.milk)) && + (!('sweetener' in data) || isValidSweetener(data.sweetener)) && + (!('happyPlace' in data) || data.happyPlace is string || data.happyPlace == null) && + (!('imageUrl' in data) || data.imageUrl is string || data.imageUrl == null); + } + + function baristaAllowedModification(data) { + return data.keys().hasOnly(['name', 'milk', 'sweetener', 'happyPlace', 'imageUrl', 'expiresAt']) && + (!('name' in data) || data.name is string) && + (!('milk' in data) || isValidMilk(data.milk)) && + (!('sweetener' in data) || isValidSweetener(data.sweetener)) && + (!('happyPlace' in data) || data.happyPlace is string || data.happyPlace == null) && + (!('imageUrl' in data) || data.imageUrl is string || data.imageUrl == null); + } + + match /latteOrders/{orderId} { + allow read: if isKiosk() || isBarista() || isModerator() || isQueue(); // prevents annonymous auth from reading + allow create: if isKiosk() && onlyHasStringName(request.resource.data); + allow delete: if isBarista(); + allow update: if (isKiosk() && kioskAllowedModification(request.resource.data)) + || (isBarista()); + } + + match /baristas/{baristaId} { + allow read: if isBarista() || isModerator(); + allow write: if isBarista(); + } + + match /latteOptions/{optionId} { + allow read: if true; + allow write: if isModerator(); + } + + match /latteImageBatches/{batchId} { + allow read: if isKiosk() || isBarista(); + allow write: if isBarista(); + } + + match /latteOrderMetadata/{orderId} { + allow read: if isKiosk() || isBarista() || isModerator() || isQueue(); + allow write: if isBarista() || isModerator(); + } + + match /recentLatteImages/{imageId} { + allow read: if isRecent(); + allow write: if false; + } + + match /machines/{machineId} { + allow read: if isBarista() || isModerator(); + allow write: if isModerator(); + } + + // Default deny for all other collections + match /{document=**} { + allow read, write: if false; + } + } +} diff --git a/genlatte/firebase/functions/.eslintrc.js.ignore b/genlatte/firebase/functions/.eslintrc.js.ignore new file mode 100644 index 0000000..0f8e2a9 --- /dev/null +++ b/genlatte/firebase/functions/.eslintrc.js.ignore @@ -0,0 +1,33 @@ +module.exports = { + root: true, + env: { + es6: true, + node: true, + }, + extends: [ + "eslint:recommended", + "plugin:import/errors", + "plugin:import/warnings", + "plugin:import/typescript", + "google", + "plugin:@typescript-eslint/recommended", + ], + parser: "@typescript-eslint/parser", + parserOptions: { + project: ["tsconfig.json", "tsconfig.dev.json"], + sourceType: "module", + }, + ignorePatterns: [ + "/lib/**/*", // Ignore built files. + "/generated/**/*", // Ignore generated files. + ], + plugins: [ + "@typescript-eslint", + "import", + ], + rules: { + "quotes": ["error", "double"], + "import/no-unresolved": 0, + "indent": ["error", 2], + }, +}; diff --git a/genlatte/firebase/functions/.gitignore b/genlatte/firebase/functions/.gitignore new file mode 100644 index 0000000..9be0f01 --- /dev/null +++ b/genlatte/firebase/functions/.gitignore @@ -0,0 +1,10 @@ +# Compiled JavaScript files +lib/**/*.js +lib/**/*.js.map + +# TypeScript v1 declaration files +typings/ + +# Node.js dependency directory +node_modules/ +*.local \ No newline at end of file diff --git a/genlatte/firebase/functions/CONTEXT.md b/genlatte/firebase/functions/CONTEXT.md new file mode 100644 index 0000000..ee178fd --- /dev/null +++ b/genlatte/firebase/functions/CONTEXT.md @@ -0,0 +1,44 @@ +# Cloud Functions Platform (`firebase/functions/`) + +## Purpose +This directory represents the Node.js environment and build configuration for all project Cloud Functions. It provides the infrastructure necessary to develop, test (via emulators), and deploy backend logic to Firebase. + +## Detailed File Overviews + +- **`package.json`**: + - **Description**: Standard Node.js manifest file. + - **Core Logic**: + - Declares dependencies including `@google/genai` for Vertex AI interaction and `sharp` for image processing. + - Defines build scripts (`tsc`) and emulator utility commands (`serve:all`). + - Restricts the runtime engine to Node 24 for modern feature support. + +- **`tsconfig.json`**: + - **Description**: TypeScript compiler configuration, ensuring strict type checking and consistent output in the `lib/` directory. + +- **`src/`**: + - **Description**: The core source code directory (see [src/CONTEXT.md](src/CONTEXT.md)). + +- **`lib/`**: + - **Description**: Generated directory containing compiled JavaScript intended for deployment. + +## Dependencies/Relationships + +- **Firebase CLI**: Necessary for running the emulators and deploying the package. +- **Node.js 24**: The required runtime environment. +- **Shared Data**: Indirectly related to the shared project data structures maintained in cross-module packages. + +## Usage/Exports + +### Development Workflow +To compile and run local emulators: +```bash +cd firebase/functions +npm install +npm run build +npm run serve:all +``` + +Deployment is handled via the Firebase CLI: +```bash +npm run deploy +``` diff --git a/genlatte/firebase/functions/package.json b/genlatte/firebase/functions/package.json new file mode 100644 index 0000000..1f46c17 --- /dev/null +++ b/genlatte/firebase/functions/package.json @@ -0,0 +1,36 @@ +{ + "name": "functions", + "scripts": { + "lint": "eslint --ext .js,.ts .", + "build": "tsc", + "build:watch": "tsc --watch", + "serve": "npm run build && firebase emulators:start --only functions", + "serve:all": "npm run build && firebase emulators:start", + "shell": "npm run build && firebase functions:shell", + "start": "npm run shell", + "deploy": "firebase deploy --only functions", + "logs": "firebase functions:log" + }, + "engines": { + "node": "24" + }, + "main": "lib/index.js", + "dependencies": { + "@google/genai": "^1.39.0", + "firebase-admin": "^13.6.0", + "firebase-functions": "^7.2.0", + "sharp": "^0.34.5" + }, + "devDependencies": { + "@types/sharp": "^0.31.1", + "@typescript-eslint/eslint-plugin": "^5.12.0", + "@typescript-eslint/parser": "^5.12.0", + "eslint": "^8.9.0", + "eslint-config-google": "^0.14.0", + "eslint-plugin-import": "^2.25.4", + "firebase-functions-test": "^3.4.1", + "firebase-tools": "^15.9.1", + "typescript": "^5.9.3" + }, + "private": true +} diff --git a/genlatte/firebase/functions/src/CONTEXT.md b/genlatte/firebase/functions/src/CONTEXT.md new file mode 100644 index 0000000..7004b09 --- /dev/null +++ b/genlatte/firebase/functions/src/CONTEXT.md @@ -0,0 +1,42 @@ +# Cloud Functions Source Module (`firebase/functions/src/`) + +## Purpose +This directory contains the implementation of the backend logic for GenLatte, deployed as Firebase Cloud Functions (v2). It serves as the bridge between the client-side UI and the various Google Cloud services (Firestore, Vertex AI, Cloud Tasks). + +## Detailed File Overviews + +- **`index.ts`**: + - **Description**: The main entry point and export aggregator for the entire functions package. + - **Core Logic**: + - Initializes the `firebase-admin` SDK. + - Sets global scaling options for Cloud Run (underlying Functions v2). + - Aggregates and re-exports functionality from submodules with consistent naming patterns (using `BRANCH_SUFFIX` for environment isolation). + - **Exposed Categories**: + - **Auth Triggers**: Custom claim management (`userEvents`). + - **Order Management**: Lifecycle triggers and barista actions (`ordersEvents`). + - **Generative AI**: Asynchronous image and question generation (`tasks`, `images`). + - **Infrastructure**: Moderation and printing services. + +- **`sendToPrinter.ts`**: + - **Description**: Logic for communicating with physical latte art printers. + +- **`moderation.ts`**: + - **Description**: Real-time content filtering and policy enforcement for user input. + +## Subdirectories Overview + +- **`common/`**: Shared utilities (retry logic, remote config). +- **`images/`**: High-level image generation and refinement logic. +- **`ordersEvents/`**: Core business logic for order processing. +- **`reviser/`**: AI-driven question generation for image refinement. +- **`tasks/`**: Backend workers for long-running generative AI tasks. + +## Dependencies/Relationships + +- **Firebase Admin SDK**: For cross-service interaction within GCP. +- **Vertex AI (Gemini)**: Utilized by `images` and `reviser` sub-modules for core feature logic. +- **Cloud Tasks**: Reliable task enqueuing between modules. + +## Usage/Exports + +All functions are exported from `index.ts`. For deployment, the service uses these exports to define individual Cloud Run services. diff --git a/genlatte/firebase/functions/src/common/CONTEXT.md b/genlatte/firebase/functions/src/common/CONTEXT.md new file mode 100644 index 0000000..bdfbff3 --- /dev/null +++ b/genlatte/firebase/functions/src/common/CONTEXT.md @@ -0,0 +1,34 @@ +# Common Utilities Module (`firebase/functions/src/common/`) + +## Purpose +This directory contains reusable utility functions, configuration handlers, and shared resources used across all Firebase Cloud Functions in the project. Centralizing these ensures consistency in database access, error handling, and configuration management. + +## Detailed File Overviews + +- **`retry.ts`**: + - **Description**: A generic asynchronous retry utility. + - **Core Logic**: Implements an exponential backoff strategy (`baseDelay * 2^attempt`) to gracefully handle transient failures in external services (like Generative AI APIs or database contention), with configurable retry limits. + +- **`fetchRemoteConfig.ts`**: + - **Description**: Handles dynamic configuration via Firebase Remote Config. + - **Core Logic**: Provides a type-safe way to fetch project-wide parameters such as Generative AI model names, feature flags, or threshold values, allowing for real-time application updates without redeploying code. + +- **`getDb.ts`**: + - **Description**: Centralized Firestore initialization. + - **Core Logic**: Manages the `admin.firestore()` instance, handling differences between emulator environments and production. + +- **`prompts/`**: + - **Description**: A subdirectory containing common system prompt templates used for instructing Large Language Models. + +## Dependencies/Relationships + +- **Firebase Admin SDK**: Core dependency for accessing Firestore and Remote Config. +- **Firebase Functions Logger**: Used for standardized logging of retry attempts and errors. +- **Cross-cutting**: Consumed by virtually every other module in `src/` (e.g., `images`, `ordersEvents`, `reviser`). + +## Usage/Exports + +### Shared Utilities +- `withRetry`: The primary wrapper for reliable async execution. +- `fetchRemoteConfigValues`: Method to retrieve current dynamic app settings. +- `db`: The global Firestore database instance exported for general use. diff --git a/genlatte/firebase/functions/src/common/fetchRemoteConfig.ts b/genlatte/firebase/functions/src/common/fetchRemoteConfig.ts new file mode 100644 index 0000000..7831cf7 --- /dev/null +++ b/genlatte/firebase/functions/src/common/fetchRemoteConfig.ts @@ -0,0 +1,66 @@ +import { getRemoteConfig } from "firebase-admin/remote-config"; +import { + DEFAULT_POWERUP_PROMPT, + DEFAULT_MODERATION_PROMPT, + DEFAULT_DESCRIPTION_PROMPT, + DEFAULT_QUESTION_GEN_PROMPT, + DEFAULT_SLIDER_VALUE_GEN_PROMPT, + DEFAULT_TEXT_VALUE_GEN_PROMPT +} from "./prompts"; +import { logger } from "firebase-functions/logger"; + +/** + * Fetches Remote Config values for power-up prompt and image model. + */ +export async function fetchRemoteConfigValues() { + const rc = getRemoteConfig(); + let powerupPromptInstructions = DEFAULT_POWERUP_PROMPT; + let moderationPromptInstructions = DEFAULT_MODERATION_PROMPT; + let descriptionPromptInstructions = DEFAULT_DESCRIPTION_PROMPT; + let questionGenPromptInstructions = DEFAULT_QUESTION_GEN_PROMPT; + let sliderValueGenPromptInstructions = DEFAULT_SLIDER_VALUE_GEN_PROMPT; + let textValueGenPromptInstructions = DEFAULT_TEXT_VALUE_GEN_PROMPT; + let imageModel = "gemini-3.1-flash-image-preview"; + let textModel = "gemini-3-flash-preview"; + let imageParallelism = 4; + + try { + const template = await rc.getServerTemplate({ + defaultConfig: { + "POWERUP_PROMPT_BRANCH_SUFFIX": DEFAULT_POWERUP_PROMPT, + "MODERATION_PROMPT_BRANCH_SUFFIX": DEFAULT_MODERATION_PROMPT, + "DESCRIPTION_PROMPT_BRANCH_SUFFIX": DEFAULT_DESCRIPTION_PROMPT, + "QUESTION_GEN_PROMPT_BRANCH_SUFFIX": DEFAULT_QUESTION_GEN_PROMPT, + "SLIDER_VALUE_GEN_PROMPT_BRANCH_SUFFIX": DEFAULT_SLIDER_VALUE_GEN_PROMPT, + "TEXT_VALUE_GEN_PROMPT_BRANCH_SUFFIX": DEFAULT_TEXT_VALUE_GEN_PROMPT, + "IMAGE_MODEL_BRANCH_SUFFIX": "gemini-3.1-flash-image-preview", + "TEXT_MODEL_BRANCH_SUFFIX": "gemini-3-flash-preview", + "IMAGE_PARALLELISM": 4, + }, + }); + const config = template.evaluate(); + powerupPromptInstructions = config.getString("POWERUP_PROMPT_BRANCH_SUFFIX") || powerupPromptInstructions; + moderationPromptInstructions = config.getString("MODERATION_PROMPT_BRANCH_SUFFIX") || moderationPromptInstructions; + descriptionPromptInstructions = config.getString("DESCRIPTION_PROMPT_BRANCH_SUFFIX") || descriptionPromptInstructions; + questionGenPromptInstructions = config.getString("QUESTION_GEN_PROMPT_BRANCH_SUFFIX") || questionGenPromptInstructions; + sliderValueGenPromptInstructions = config.getString("SLIDER_VALUE_GEN_PROMPT_BRANCH_SUFFIX") || sliderValueGenPromptInstructions; + textValueGenPromptInstructions = config.getString("TEXT_VALUE_GEN_PROMPT_BRANCH_SUFFIX") || textValueGenPromptInstructions; + imageModel = config.getString("IMAGE_MODEL_BRANCH_SUFFIX") || imageModel; + textModel = config.getString("TEXT_MODEL_BRANCH_SUFFIX") || textModel; + imageParallelism = config.getNumber("IMAGE_PARALLELISM") || imageParallelism; + } catch (err) { + logger.warn("Failed to fetch Remote Config, using default POWERUP_PROMPT and IMAGE_MODEL", err); + } + + return { + powerupPromptInstructions, + moderationPromptInstructions, + descriptionPromptInstructions, + questionGenPromptInstructions, + sliderValueGenPromptInstructions, + textValueGenPromptInstructions, + imageModel, + textModel, + imageParallelism + }; +} \ No newline at end of file diff --git a/genlatte/firebase/functions/src/common/getDb.ts b/genlatte/firebase/functions/src/common/getDb.ts new file mode 100644 index 0000000..dffcded --- /dev/null +++ b/genlatte/firebase/functions/src/common/getDb.ts @@ -0,0 +1,6 @@ +import { getFirestore } from "firebase-admin/firestore"; + +const DATABASE_NAME = process.env.FUNCTIONS_EMULATOR === 'true' ? '(default)' : 'DATABASE_ID_PLACEHOLDER'; +const db = getFirestore(DATABASE_NAME); + +export { db }; \ No newline at end of file diff --git a/genlatte/firebase/functions/src/common/index.ts b/genlatte/firebase/functions/src/common/index.ts new file mode 100644 index 0000000..cfbb9ed --- /dev/null +++ b/genlatte/firebase/functions/src/common/index.ts @@ -0,0 +1,3 @@ +import { withRetry } from "./retry"; +import { db } from "./getDb"; +export { withRetry, db }; \ No newline at end of file diff --git a/genlatte/firebase/functions/src/common/prompts/CONTEXT.md b/genlatte/firebase/functions/src/common/prompts/CONTEXT.md new file mode 100644 index 0000000..6c59a5c --- /dev/null +++ b/genlatte/firebase/functions/src/common/prompts/CONTEXT.md @@ -0,0 +1,16 @@ +# Common Prompts (Firebase) + +**Purpose:** +Contains fundamental LLM prompt instructions shared by various AI-driven Cloud Functions, specifically aimed at initializing image generation constraints. + +**Detailed File Overviews:** + +- `index.ts`: + - **Description**: Barrel export file. + +- `powerup.ts`: + - **Description**: The fundamental generative augmentation prompt. + - **Core Logic**: Instructs the LLM to expand a user's short input into a highly detailed, two-paragraph prompt optimized for Latte Art (e.g. telling the LLM to omit details about physical coffee cups and backgrounds, ensuring the result is constrained to just the floating pattern of a latte's crema). + +**Dependencies/Relationships:** +- Injected into prompt chains utilized by tools sitting inside `functions/src/images/` to refine user requests before sending them to the primary image generation layer. diff --git a/genlatte/firebase/functions/src/common/prompts/description.ts b/genlatte/firebase/functions/src/common/prompts/description.ts new file mode 100644 index 0000000..82ab72e --- /dev/null +++ b/genlatte/firebase/functions/src/common/prompts/description.ts @@ -0,0 +1 @@ +export const DEFAULT_DESCRIPTION_PROMPT = `Generate a one line description of this image. Keep it under 6 words at absolute most. Detail is not important -- BREVITY IS IMPORTANT.`; diff --git a/genlatte/firebase/functions/src/common/prompts/index.ts b/genlatte/firebase/functions/src/common/prompts/index.ts new file mode 100644 index 0000000..4108135 --- /dev/null +++ b/genlatte/firebase/functions/src/common/prompts/index.ts @@ -0,0 +1,15 @@ +import { DEFAULT_POWERUP_PROMPT } from "./powerup"; +import { DEFAULT_MODERATION_PROMPT } from "./moderation"; +import { DEFAULT_DESCRIPTION_PROMPT } from "./description"; +import { DEFAULT_QUESTION_GEN_PROMPT } from "./reviserQuestion"; +import { DEFAULT_SLIDER_VALUE_GEN_PROMPT } from "./reviserSlider"; +import { DEFAULT_TEXT_VALUE_GEN_PROMPT } from "./reviserText"; + +export { + DEFAULT_POWERUP_PROMPT, + DEFAULT_MODERATION_PROMPT, + DEFAULT_DESCRIPTION_PROMPT, + DEFAULT_QUESTION_GEN_PROMPT, + DEFAULT_SLIDER_VALUE_GEN_PROMPT, + DEFAULT_TEXT_VALUE_GEN_PROMPT +}; \ No newline at end of file diff --git a/genlatte/firebase/functions/src/common/prompts/moderation.ts b/genlatte/firebase/functions/src/common/prompts/moderation.ts new file mode 100644 index 0000000..fda92ab --- /dev/null +++ b/genlatte/firebase/functions/src/common/prompts/moderation.ts @@ -0,0 +1,30 @@ +export const DEFAULT_MODERATION_PROMPT = `You are an expert content moderation bot. + +**TASK** +Your goal is to determine if a given string of text is inappropriate to be used as a username or "Happy Place". + +**CRITERIA FOR INAPPROPRIATE CONTENT** +A string is considered inappropriate if it contains: +1. **Profanity:** Obscene or vulgar language. +2. **Hate Speech:** Attacks on individuals or groups based on identity. +3. **Glorification of Violence:** Promoting or celebrating violent acts. +4. **Trivialization of Tragedy:** Using historical atrocities or disasters in a trivial context (e.g., "1945 Hiroshima" as a happy place). +5. **Contemporary issues:** Referencing current news cycle items, political figures, polarizing stories, etc + +**INSTRUCTIONS** +- Analyze the input text. +- If the text meets any of the criteria above, it is NOT allowed. Set \`isAllowed\` to \`false\` and provide a \`moderationReason\`. +- If the text is acceptable, it IS allowed. Set \`isAllowed\` to \`true\` and provide an empty \`moderationReason\`. +- Your output must be valid JSON matching the specified schema. + +**EXAMPLES** + +TEXT TO MODERATE: The beach at sunset +{"isAccepted": true, "reason": null} + +TEXT TO MODERATE: [imagine something unsavory here] +{"isAccepted": false, "reason": "is unsavory"} +--- + +TEXT TO MODERATE: +`; diff --git a/genlatte/firebase/functions/src/common/prompts/powerup.ts b/genlatte/firebase/functions/src/common/prompts/powerup.ts new file mode 100644 index 0000000..2229516 --- /dev/null +++ b/genlatte/firebase/functions/src/common/prompts/powerup.ts @@ -0,0 +1,13 @@ +export const DEFAULT_POWERUP_PROMPT = ` +Give me a prompt for a photo generation model that is extremely detailed and at least two paragraphs long. +Refrain from showing any humans in the output images. +Make it a flat vector illustration with a whimsical, minimalist aesthetic. +Key characteristics include: +Geometric Simplification: The landscape elements—such as the pine trees and bushes—are reduced to clean, angular, and stylized geometric shapes rather than realistic textures. +Palette and Gradation: It utilizes a harmonious, soft-toned color palette. While the shapes are flat, the atmosphere is created through subtle linear gradients in the sky and soft, layered color blocking to suggest depth and light. +Contoured Line Work: The composition is defined by clean, consistent-weight outlines that give the characters and objects a structured, pop-up book quality. +Stylized Proportion: The subject exhibits \"chibi-adjacent\" or simplified character design, favoring clear, readable features and an expressive, cheerful personality over anatomical realism. +Graphic Balance: There is a strong emphasis on intentional negative space and clean, rhythmic curves (such as the S-curve of the path), creating a balanced, orderly, and serene visual flow. +The output image must use a sepia color palette, nothing else. There can be no other colors except sepia palette. +Photo image: +`; \ No newline at end of file diff --git a/genlatte/firebase/functions/src/common/prompts/reviserQuestion.ts b/genlatte/firebase/functions/src/common/prompts/reviserQuestion.ts new file mode 100644 index 0000000..fa8bbee --- /dev/null +++ b/genlatte/firebase/functions/src/common/prompts/reviserQuestion.ts @@ -0,0 +1,54 @@ +export const DEFAULT_QUESTION_GEN_PROMPT = ` +You are an expert prompt engineer specializing in refining user prompts for image generation models. + +Your task is to analyze an initial image and generate 2 to 4 follow-up questions to clarify the user's intent and add crucial details. + +Questions MUST be short. Eight to ten words is the maximum length for any question. Questions longer than 10 words will be rejected. Forgo any extraneous phrasing and get straight to the point. +Questions MUST be about the input image and things to change specifically in the image. Do not ask questions about things that are not in the image. +For instance, if there is a particular object in the image, you can ask about it. If there is a particular style in the image, you can ask about it. + +Your questions should help define key aspects like: +- Composition and Framing (e.g., close-up, wide shot) +- Lighting and Atmosphere (e.g., golden hour, cinematic lighting) +- Level of Detail + +NEVER ASK: +- To change the color. The output must always be Sepia. + +**Output Format:** +You must generate a single JSON array containing 2 to 4 question objects. Each object must have the following structure: +- \`question\`: (string) The text of the question you are asking the user. +- \`type\`: (string) The expected type of the answer. This must be EXACTLY ONE of: "multipleChoiceQuestion", "zeroToOneQuestion", "negativeOneToOneQuestion", or "textQuestion". + +--- +**Example:** + +**Initial User Image Description:** +A dog in a field + +**Your Output:** +[ + { + "question": "Should the scene be more or less whimsical?", + "type": "negativeOneToOneQuestion" + }, + { + "question": "What kind of dog should it be?", + "type": "textQuestion" + }, + { + "question": "What sort of lighting should there be?", + "type": "multipleChoiceQuestion" + }, + { + "question": "How much should the image be stylized?", + "type": "zeroToOneQuestion" + } +] + +**Initial User Image Description:** +{{user_supplied_image}} + +--- + +Now, generate a minumum of 2 revision questions and no more than 4 revision questions for the supplied image.`; diff --git a/genlatte/firebase/functions/src/common/prompts/reviserSlider.ts b/genlatte/firebase/functions/src/common/prompts/reviserSlider.ts new file mode 100644 index 0000000..8b8659d --- /dev/null +++ b/genlatte/firebase/functions/src/common/prompts/reviserSlider.ts @@ -0,0 +1,53 @@ +export const DEFAULT_SLIDER_VALUE_GEN_PROMPT = ` +You are an expert label maker specializing in what to set a minimum value label to and a maximum value label to based on a users question. + +Your task is to analyze the supplied user question and then generate a minimum value label and a maximum value label for the slider. + +**Output Format:** +You must generate a single JSON object containing a minimum value label and a maximum value label. The object must have the following structure: +- \`minValueLabel\`: (string) The label to display for the minimum value of the slider. +- \`maxValueLabel\`: (string) The label to display for the maximum value of the slider. + +--- +**Example:** + +User Question: "How much stylistic influence should be applied? (0 is less stylized, 1 is highly stylized)" + +Your Output: +{ + "minValueLabel": "Less Stylized", + "maxValueLabel": "Highly Stylized" +} + +User Question: "On a scale from -1 to 1, how bright should the image be? (-1 is very dark, 1 is extremely bright)" + +Your Output: +{ + "minValueLabel": "Very Dark", + "maxValueLabel": "Extremely Bright" +} + +User Question: "What should the camera angle be? (0 is ground level, 1 is bird's eye view)" + +Your Output: +{ + "minValueLabel": "Ground Level", + "maxValueLabel": "Bird's Eye View" +} + +User Question: "What level of realism do you want? (-1 for cartoon/abstract, 1 for photorealistic)" + +Your Output: +{ + "minValueLabel": "Cartoon/Abstract", + "maxValueLabel": "Photorealistic" +} + +User Question: "{{user_supplied_question}}" + +--- + +Now, generate a minimum value label and a maximum value label for the slider based on the supplied user question. + +User Question: {{userSuppliedQuestion}} +`; diff --git a/genlatte/firebase/functions/src/common/prompts/reviserText.ts b/genlatte/firebase/functions/src/common/prompts/reviserText.ts new file mode 100644 index 0000000..9ce6572 --- /dev/null +++ b/genlatte/firebase/functions/src/common/prompts/reviserText.ts @@ -0,0 +1,61 @@ +export const DEFAULT_TEXT_VALUE_GEN_PROMPT = ` +You are an expert multiple choice creator helping users pick out up to four values as answers for a +multiple choice question. + +Your task is to analyze the supplied question and generate 4 choices that a user would reasonably expect to be valid answers to the question. + +Each choice in the multiple choices you generate should be one or two words and VERY RARELY, possibly three words; but NEVER MORE. Any choices phrased in 4 or more words will be rejected. + +**Output Format:** +You must generate an array of choices that a user would reasonably expect to be valid answers to the question. + +--- +**Example:** + +User Question: "What should the camera angle be?" + +Your Output: +[ + "Ground Level", + "Bird's Eye View", + "Low Angle", + "High Angle" +] + +User Question: "What is the weather like?" + +Your Output: +[ + "Sunny", + "Rainy", + "Cloudy", + "Snowy" +] + +User Question: "What kind of lighting should be used?" + +Your Output: +[ + "Golden Hour", + "Cinematic", + "Studio", + "Neon" +] + +User Question: "What kind of environment is the scene set in?" + +Your Output: +[ + "Indoors", + "Outdoors (Nature)", + "Urban/Cityscape", + "Space/Sci-Fi" +] + +User Question: "{{user_supplied_question}}" + +--- +Now, generate 4 choices that a user would reasonably expect to be valid answers to the question. + +User Question: {{userSuppliedQuestion}} +`; diff --git a/genlatte/firebase/functions/src/common/retry.ts b/genlatte/firebase/functions/src/common/retry.ts new file mode 100644 index 0000000..7e4d306 --- /dev/null +++ b/genlatte/firebase/functions/src/common/retry.ts @@ -0,0 +1,27 @@ +import * as logger from "firebase-functions/logger"; + +/** + * Helper function to retry an async operation with exponential backoff. + */ +export async function withRetry( + fn: () => Promise, + maxRetries: number = 3, + baseDelayMs: number = 1000, + orderId?: string +): Promise { + let lastError: any; + for (let i = 0; i <= maxRetries; i++) { + try { + return await fn(); + } catch (e) { + lastError = e; + logger.error(`Error for orderId ${orderId}: ${JSON.stringify(e)}`); + if (i < maxRetries) { + const delay = baseDelayMs * Math.pow(2, i); + logger.warn(`Retry attempt ${i + 1} after error for orderId ${orderId}. Retrying in ${delay}ms...`); + await new Promise((resolve) => setTimeout(resolve, delay)); + } + } + } + throw lastError; +} diff --git a/genlatte/firebase/functions/src/images/CONTEXT.md b/genlatte/firebase/functions/src/images/CONTEXT.md new file mode 100644 index 0000000..b1c299e --- /dev/null +++ b/genlatte/firebase/functions/src/images/CONTEXT.md @@ -0,0 +1,45 @@ +# Images Module (`firebase/functions/src/images/`) + +## Purpose +This directory serves as the core Generative AI image engine for the project. It handles the lifecycle of image generation from initial prompt creation to refinement based on user feedback, integrating directly with Google Cloud's Vertex AI (specifically Gemini 3 Pro models). + +## Detailed File Overviews + +- **`generateImageWithPro.ts`**: + - **Description**: A robust low-level wrapper around the Vertex AI Image generation API. + - **Core Logic**: Configures advanced generation parameters (aspect ratio, MIME type, thinking level) and includes retry logic with exponential backoff to handle transient AI service errors. + +- **`generateRevisedImages.ts`**: + - **Description**: An `onCall` function that processes user feedback (answers to generated questions) to create a new batch of refined images. + - **Core Logic**: + - Aggregates user answers and moderates text-based input. + - Calls the revised prompt generator to translate feedback into new AI prompts. + - Creates a new `latteImageBatch` in Firestore linked to the parent batch. + - Enqueues multiple `taskGenerateImage` calls via the Cloud Task queue for asynchronous generation. + +- **`generateDescriptions.ts`**: + - **Description**: Uses Multimodal LLMs to analyze generated images and provide textual descriptions. + +- **`promptsForHappyPlace.ts`**: + - **Description**: Contains the prompt engineering logic that converts a user's "Happy Place" description into a dense, detailed prompt optimized for image generation. + +- **`selectImage.ts` / `rejectImage.ts`**: + - **Description**: Handle the user's final decision on a batch. `selectImage` marks an image as the final choice for the order, while `rejectImage` marks the batch as rejected (e.g., if the user wants to try again). + +## Dependencies/Relationships + +- **Vertex AI (Gemini)**: The primary engine for both image generation and multimodal vision analysis. +- **Firebase Remote Config**: Dynamically injects model IDs and feature flags. +- **Firestore**: Tracks the state of image batches (`latteImageBatches`) and links them to orders (`latteOrderMetadata`). +- **Cloud Tasks**: Used by `generateRevisedImages.ts` to trigger asynchronous image creation without blocking the client. + +## Usage/Exports + +### Callable Functions +- `generateRevisedImages`: Triggered by the frontend when a user submits refinement answers. +- `selectImage`: Triggered when a user chooses their final latte art. +- `rejectImage`: Triggered when a user dislikes a whole batch. + +### Internal Services +- `generateImageWithPro`: Utility for raw image generation. +- `generateDescription`: Utility for analyzing image content. diff --git a/genlatte/firebase/functions/src/images/configs/CONTEXT.md b/genlatte/firebase/functions/src/images/configs/CONTEXT.md new file mode 100644 index 0000000..b811821 --- /dev/null +++ b/genlatte/firebase/functions/src/images/configs/CONTEXT.md @@ -0,0 +1,17 @@ +# Image Gen Configs (Firebase) + +**Purpose:** +Stores exact execution configuration logic blocks required when querying Gemini for generative capabilities. + +**Detailed File Overviews:** + +- `happyPlacePromptPower.ts`: + - **Description**: Generative Model config object for the Happy Place expansion call. + - **Core Logic**: Declares parameters explicitly for expanding the user's "Happy Place" input string. + - Sets `temperature: 1` aiming for highly creative variance. + - Sets `thinkingLevel: ThinkingLevel.HIGH` to employ advanced reasoning. + - Demands the AI force structured JSON outputs (`responseMimeType: 'application/json'`). + - Disables all typical `SafetySettings` blocks (`HarmBlockThreshold.BLOCK_NONE`) allowing the model leeway to respond accurately to highly diverse edge-cases. + +**Dependencies/Relationships:** +- Consumed directly when spinning up an instance of the `@google/genai` client inside Cloud Functions. diff --git a/genlatte/firebase/functions/src/images/configs/happyPlacePromptPower.ts b/genlatte/firebase/functions/src/images/configs/happyPlacePromptPower.ts new file mode 100644 index 0000000..fcbb9c8 --- /dev/null +++ b/genlatte/firebase/functions/src/images/configs/happyPlacePromptPower.ts @@ -0,0 +1,36 @@ +import { HarmBlockThreshold, HarmCategory, ThinkingLevel } from "@google/genai"; + +export const happyPlacePromptPowerGenConfig = { + maxOutputTokens: 65535, + temperature: 1, + topP: 0.95, + seed: 0, + thinkingConfig: { + thinkingLevel: ThinkingLevel.HIGH, + }, + safetySettings: [ + { + category: HarmCategory.HARM_CATEGORY_HATE_SPEECH, + threshold: HarmBlockThreshold.BLOCK_NONE, + }, + { + category: HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT, + threshold: HarmBlockThreshold.BLOCK_NONE, + }, + { + category: HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT, + threshold: HarmBlockThreshold.BLOCK_NONE, + }, + { + category: HarmCategory.HARM_CATEGORY_HARASSMENT, + threshold: HarmBlockThreshold.BLOCK_NONE, + } + ], + responseMimeType: 'application/json', + responseSchema: { + type: "ARRAY", + items: { + type: "STRING", + }, + }, +}; \ No newline at end of file diff --git a/genlatte/firebase/functions/src/images/generateDescriptions.ts b/genlatte/firebase/functions/src/images/generateDescriptions.ts new file mode 100644 index 0000000..06615fc --- /dev/null +++ b/genlatte/firebase/functions/src/images/generateDescriptions.ts @@ -0,0 +1,41 @@ +import { logger } from "firebase-functions/logger"; +import { withRetry } from "../common"; +import { GoogleGenAI } from "@google/genai"; + +import { fetchRemoteConfigValues } from "../common/fetchRemoteConfig"; + +export const generateDescription = async (base64ImageFile: string, orderId?: string) => { + const projectId = process.env.GCP_PROJECT || process.env.GCLOUD_PROJECT; + const genAI = new GoogleGenAI({ vertexai: true, project: projectId, location: 'global' }); + const { descriptionPromptInstructions } = await fetchRemoteConfigValues(); + const prompt = [ + { + inlineData: { + mimeType: "image/jpeg", + data: base64ImageFile, + } + }, + {text: descriptionPromptInstructions} + ]; + const genConfig = { + maxOutputTokens: 20000, + temperature: 1, + topP: 0.95, + }; + return await withRetry(async () => { + const chat = genAI.chats.create({ + model: "gemini-3.1-flash-lite-preview", + config: genConfig + }); + const result = await chat.sendMessage({message:prompt}); + if(!result.text) { + logger.error("No text data returned from Gemini pro for image description"); + throw new Error("No text data returned from Gemini Pro"); + } + logger.log(`Description: ${result.text}`); + return result.text; + }, 5, 1000, orderId).catch((e) => { + logger.error(`Error generating description after retries for orderId: ${orderId}`, e); + return 'could not generate a description'; + }); +} \ No newline at end of file diff --git a/genlatte/firebase/functions/src/images/generateImageWithPro.ts b/genlatte/firebase/functions/src/images/generateImageWithPro.ts new file mode 100644 index 0000000..41c0e62 --- /dev/null +++ b/genlatte/firebase/functions/src/images/generateImageWithPro.ts @@ -0,0 +1,97 @@ +import * as logger from "firebase-functions/logger"; +import { GoogleGenAI, HarmBlockThreshold, HarmCategory, ThinkingLevel } from '@google/genai'; +import { withRetry } from "../common"; +import { fetchRemoteConfigValues } from "../common/fetchRemoteConfig"; + +/** + * Generates four images using Gemini 3 Pro (Image Preview) based on a powered-up prompt. + */ +export async function generateImageWithPro(prompt: string, orderId?: string): Promise<{image: string, prompt: string}> { + const { imageModel } = await fetchRemoteConfigValues(); + logger.log("Image model", imageModel); + logger.log(`Generating image for prompt: ${prompt}`); + const projectId = process.env.GCP_PROJECT || process.env.GCLOUD_PROJECT; + logger.log("Project ID", projectId); + const genAI = new GoogleGenAI({ vertexai: true, project: projectId, location: 'global' }); + const proConfig: any = { + maxOutputTokens: 32768, + temperature: 1, + topP: 0.95, + responseModalities: ["IMAGE"] as any, + imageConfig: { + aspectRatio: "1:1", + imageSize: "512", + outputMimeType: "image/png", + }, + safetySettings: [ + { + category: HarmCategory.HARM_CATEGORY_HATE_SPEECH, + threshold: HarmBlockThreshold.BLOCK_ONLY_HIGH, + }, + { + category: HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT, + threshold: HarmBlockThreshold.BLOCK_ONLY_HIGH, + }, + { + category: HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT, + threshold: HarmBlockThreshold.BLOCK_ONLY_HIGH, + }, + { + category: HarmCategory.HARM_CATEGORY_HARASSMENT, + threshold: HarmBlockThreshold.BLOCK_ONLY_HIGH, + } + ], + }; + if(imageModel.includes("gemini-3")) { + proConfig.thinkingConfig = { + thinkingLevel: ThinkingLevel.MINIMAL, + }; + } + + return await withRetry(async () => { + let base64Data = ""; + let mimeType = "image/png"; + + if (imageModel.includes('gemini-3')) { + const resultStream = await genAI.models.generateContentStream({ + model: imageModel, + contents: [{ role: 'user', parts: [{ text: prompt }] }], + config: proConfig + }); + for await (const chunk of resultStream) { + const candidate = chunk.candidates?.[0]; + const imagePart = candidate?.content?.parts?.find((p: any) => p.inlineData); + if (imagePart && imagePart.inlineData && imagePart.inlineData.data) { + base64Data += imagePart.inlineData.data; + mimeType = imagePart.inlineData.mimeType || mimeType; + } + } + } else { + const result = await genAI.models.generateContent({ + model: imageModel, + contents: [{ role: 'user', parts: [{ text: prompt }] }], + config: proConfig + }); + const candidate = result.candidates?.[0]; + const imagePart = candidate?.content?.parts?.find((p: any) => p.inlineData); + if (imagePart && imagePart.inlineData && imagePart.inlineData.data) { + base64Data = imagePart.inlineData.data; + mimeType = imagePart.inlineData.mimeType || mimeType; + } + } + + if (base64Data.length > 0) { + return { + image: `data:${mimeType};base64,${base64Data}`, + prompt: prompt + }; + } + throw new Error("No image data returned from Gemini Pro"); + }, 5, 1000, orderId).catch((e) => { + logger.error(`Error generating image after retries for orderId: ${orderId}`, e); + return { + image: 'could not generate an image', + prompt: prompt + }; + }); +} \ No newline at end of file diff --git a/genlatte/firebase/functions/src/images/generateRevisedImages.ts b/genlatte/firebase/functions/src/images/generateRevisedImages.ts new file mode 100644 index 0000000..3d955eb --- /dev/null +++ b/genlatte/firebase/functions/src/images/generateRevisedImages.ts @@ -0,0 +1,71 @@ +import { onCall } from "firebase-functions/https"; +import { db } from "../common"; +import { SEVEN_DAYS_MILLIS } from "../ordersEvents/common"; +import { generateRevisedImagesPrompts } from "./promptsForHappyPlace"; +import { word_moderation } from "../moderation"; +import { logger } from "firebase-functions/logger"; +import { FieldValue, Timestamp } from "firebase-admin/firestore"; +import { getFunctions } from "firebase-admin/functions"; + + + +export const generateRevisedImages = onCall({ + memory: "8GiB", + timeoutSeconds: 540, +}, async (request) => { + const { imageBatchId, imageIndex, answers } = request.data; + const existingBatchImg = await db.collection("latteImageBatches").doc(imageBatchId).get(); + const existingBatchImgData = existingBatchImg.data(); + const previousPrompt = existingBatchImgData?.[`${imageIndex}`].prompt; + const orderId = existingBatchImgData?.orderId; + logger.info(`generateRevisedImages called for orderId: ${orderId}`); + + const answersToQuestions: string = (await Promise.all(Object.entries(answers).map(async ([questionId, answerValue]: [string, any]) => { + const questionMap = existingBatchImgData?.[`${imageIndex}`].questions?.find((q: any) => { + logger.info(`${q.id} vs ${questionId}`); + return q.id === questionId; + }); + const question = questionMap?.question; + + logger.info(`Processing answer for question ${questionId}: ${question} :: ${answerValue}`); + + /// Only text questions have UCG as answers. The other question types only choose from + /// Gemini-provided options. + if (question.type === "textQuestion") { + const {moderation} = await word_moderation(answerValue, orderId); + if(!moderation.isAllowed) { + logger.log(`Answer ${answerValue} contains profane language.`); + answerValue = "Smiley Face"; + } + } + return `${question}: ${answerValue}`; + }))).join('\n'); + + const revisedPrompts = await generateRevisedImagesPrompts(previousPrompt, answersToQuestions, orderId); + + const latteImageBatchDoc = db.collection("latteImageBatches").doc(); + await latteImageBatchDoc.create({ + orderId: orderId, + createdAt: FieldValue.serverTimestamp(), + expireAt: Timestamp.fromMillis(Date.now() + SEVEN_DAYS_MILLIS), + parent: { + id: imageBatchId, + imageIndex: imageIndex + } + }); + + const latteOrderMetadataRef = db.collection("latteOrderMetadata").doc(orderId); + + await latteOrderMetadataRef.update({ + "imageBatchId": latteImageBatchDoc.id, + }); + + for(let i = 0; i < revisedPrompts.length; i++) { + getFunctions().taskQueue("taskGenerateImageBranchSuffix").enqueue({ + prompt: revisedPrompts[i], + imageBatchId: latteImageBatchDoc.id, + index: i, + isRevision: true + }); + } +}); diff --git a/genlatte/firebase/functions/src/images/index.ts b/genlatte/firebase/functions/src/images/index.ts new file mode 100644 index 0000000..49d8b69 --- /dev/null +++ b/genlatte/firebase/functions/src/images/index.ts @@ -0,0 +1,15 @@ +import { generateDescription } from "./generateDescriptions"; +import { generateImageWithPro } from "./generateImageWithPro"; +import { generateRevisedImages } from "./generateRevisedImages"; +import { generatePromptsForHappyPlace } from "./promptsForHappyPlace"; +import { rejectRevision } from "./rejectImage"; +import { selectImage } from "./selectImage"; + +export { + generateDescription, + generateImageWithPro, + generatePromptsForHappyPlace, + generateRevisedImages, + rejectRevision, + selectImage +} \ No newline at end of file diff --git a/genlatte/firebase/functions/src/images/promptsForHappyPlace.ts b/genlatte/firebase/functions/src/images/promptsForHappyPlace.ts new file mode 100644 index 0000000..fae2bd8 --- /dev/null +++ b/genlatte/firebase/functions/src/images/promptsForHappyPlace.ts @@ -0,0 +1,140 @@ +import { logger } from "firebase-functions/logger"; +import { GoogleGenAI } from "@google/genai"; +import { fetchRemoteConfigValues } from "../common/fetchRemoteConfig"; +import { withRetry } from "../common"; +import { happyPlacePromptPowerGenConfig } from "./configs/happyPlacePromptPower"; + +/** + * Enhances a user prompt for image generation using Gemini 3 Flash. + */ +async function powerUpPromptLogic(prompt: string, genAI: GoogleGenAI, powerupPromptInstructions: string, textModel: string, orderId?: string): Promise { + + const prePoweredUpPrompt = ` + ${powerupPromptInstructions} + + Please generate 4 distinct variations of the prompt based on the original prompt. + Return the response as a valid JSON array of strings. e.g. ["prompt1", "prompt2", "prompt3", "prompt4"]. + Do not include any markdown formatting or code blocks in the response, just the raw JSON. + + ORIGINAL PROMPT: + ${prompt} + + OUTPUT PROMPTS (JSON): + `; + + return await withRetry(async () => { + const chat = genAI.chats.create({ model: textModel, config: happyPlacePromptPowerGenConfig }); + const result = await chat.sendMessage({ message: prePoweredUpPrompt }); + if (!result.text) { + logger.log("No text returned from Gemini Flash"); + throw new Error("No text returned from Gemini Flash"); + } + + try { + const prompts = JSON.parse(result.text); + if (Array.isArray(prompts) && prompts.length > 0) { + // Ensure we have exactly 4 prompts, or at least return what we have + return prompts.slice(0, 4).map(p => String(p)); + } + // Fallback if not an array + return [result.text]; + } catch (e) { + logger.warn("Failed to parse JSON from powerUpPromptLogic, returning raw text as single prompt", e); + return [result.text]; + } + }, 3, 1000, orderId); +} + +export const generatePromptsForHappyPlace = async (prompt: string, orderId?: string) => { + if (!prompt || prompt === "") { + throw new Error("Prompt must not be null or empty"); + } + + try { + logger.info(`Generating prompts for prompt: ${prompt}`); + + const projectId = process.env.GCP_PROJECT || process.env.GCLOUD_PROJECT; + const genAI = new GoogleGenAI({ vertexai: true, project: projectId, location: 'global' }); + + const { powerupPromptInstructions, textModel } = await fetchRemoteConfigValues(); + + const poweredUpPrompts = await powerUpPromptLogic(prompt, genAI, powerupPromptInstructions, textModel, orderId); + + return poweredUpPrompts; + } catch (e: any) { + logger.error("Global error in generatePromptsForHappyPlace", e); + // Safely return the error to the client + return []; + } +} + +/** + * Enhances a user prompt for image generation using Gemini 3 Flash. + */ +async function powerUpRevisedPromptLogic(prompt: string, genAI: GoogleGenAI, powerupPromptInstructions: string, textModel: string, answers: string, orderId?: string): Promise { + + const prePoweredUpPrompt = ` + ${powerupPromptInstructions} + + Please generate 4 distinct variations of the prompt based on the original prompt and the instructions + to revise the image. + Return the response as a valid JSON array of strings. e.g. ["prompt1", "prompt2", "prompt3", "prompt4"]. + Do not include any markdown formatting or code blocks in the response, just the raw JSON. + + ORIGINAL PROMPT: + ${prompt} + + Instructions for revising the original image: + ${answers} + + OUTPUT PROMPTS (JSON): + `; + + return await withRetry(async () => { + const chat = genAI.chats.create({ model: textModel, config: happyPlacePromptPowerGenConfig }); + const result = await chat.sendMessage({ message: prePoweredUpPrompt }); + if (!result.text) { + logger.log("No text returned from Gemini Flash"); + throw new Error("No text returned from Gemini Flash"); + } + + try { + const prompts = JSON.parse(result.text); + if (Array.isArray(prompts) && prompts.length > 0) { + // Ensure we have exactly 4 prompts, or at least return what we have + return prompts.slice(0, 4).map(p => String(p)); + } + // Fallback if not an array + return [result.text]; + } catch (e) { + logger.warn("Failed to parse JSON from powerUpPromptLogic, returning raw text as single prompt", e); + return [result.text]; + } + }, 3, 1000, orderId); +} + +export const generateRevisedImagesPrompts = async (prompt: string, answers: string, orderId?: string) => { + if (!prompt || prompt === "") { + throw new Error("Prompt must not be null or empty"); + } + if (!answers || answers.length === 0) { + throw new Error("Answers must not be null or empty"); + } + + try { + logger.info(`Generating prompts for prompt: ${prompt}`); + + const projectId = process.env.GCP_PROJECT || process.env.GCLOUD_PROJECT; + const genAI = new GoogleGenAI({ vertexai: true, project: projectId, location: 'global' }); + + const { powerupPromptInstructions, textModel } = await fetchRemoteConfigValues(); + + const revisedPrompts = await powerUpRevisedPromptLogic(prompt, genAI, powerupPromptInstructions, textModel, answers, orderId); + + return revisedPrompts; + } catch (e: any) { + logger.error("Global error in generateRevisedImagesPrompts", e); + // Safely return the error to the client + return []; + } +} \ No newline at end of file diff --git a/genlatte/firebase/functions/src/images/rejectImage.ts b/genlatte/firebase/functions/src/images/rejectImage.ts new file mode 100644 index 0000000..881467b --- /dev/null +++ b/genlatte/firebase/functions/src/images/rejectImage.ts @@ -0,0 +1,20 @@ +import { onCall } from "firebase-functions/https"; +import { logger } from "firebase-functions/logger"; +import { db } from "../common"; + +export const rejectRevision = onCall({ + +}, async (request) => { + const { imageBatchId } = request.data; + const latteImageBatchDoc = db.collection("latteImageBatches").doc(imageBatchId); + const latteImageBatchData = await latteImageBatchDoc.get(); + const orderId = latteImageBatchData.data()?.orderId; + logger.info(`rejectRevision called for orderId: ${orderId}`); + logger.log(`Rejecting LatteImageBatch ${imageBatchId} for Order ${orderId}`); + const parentImageBatchId = latteImageBatchData.data()?.parent.id; + logger.log(`Reverting OrderId ${orderId} to LatteImageBatch ${parentImageBatchId}`); + const latteOrderMetadataRef = db.collection("latteOrderMetadata").doc(orderId); + await latteOrderMetadataRef.update({ + "imageBatchId": parentImageBatchId + }); +}) \ No newline at end of file diff --git a/genlatte/firebase/functions/src/images/selectImage.ts b/genlatte/firebase/functions/src/images/selectImage.ts new file mode 100644 index 0000000..edb7e9a --- /dev/null +++ b/genlatte/firebase/functions/src/images/selectImage.ts @@ -0,0 +1,20 @@ +import { onCall } from "firebase-functions/https"; +import { logger } from "firebase-functions/logger"; +import { db } from "../common"; + +export const selectImage = onCall({ + +}, async (request) => { + const { imageBatchId, imageIndex } = request.data; + const latteImageBatchDoc = db.collection("latteImageBatches").doc(imageBatchId); + const latteImageBatchData = await latteImageBatchDoc.get(); + const imageUrl = latteImageBatchData.data()?.[imageIndex]?.imageUrl; + const orderId = latteImageBatchData.data()?.orderId; + logger.info(`selectImage called for orderId: ${orderId}`); + const latteOrderMetadataRef = db.collection("latteOrderMetadata").doc(orderId); + await latteOrderMetadataRef.update({ + "imageUrl": imageUrl, + // Do not set status == submitted here; that is its own step + // separate from selecting an image + }); +}); \ No newline at end of file diff --git a/genlatte/firebase/functions/src/index.ts b/genlatte/firebase/functions/src/index.ts new file mode 100644 index 0000000..bbb19d0 --- /dev/null +++ b/genlatte/firebase/functions/src/index.ts @@ -0,0 +1,57 @@ +/** + * Import function triggers from their respective submodules: + * + * import {onCall} from "firebase-functions/v2/https"; + * import {onDocumentWritten} from "firebase-functions/v2/firestore"; + * + * See a full list of supported triggers at https://firebase.google.com/docs/functions + */ + +import { setGlobalOptions } from "firebase-functions"; +import * as admin from "firebase-admin"; + +// Initialize Admin SDK +admin.initializeApp(); + +setGlobalOptions({ maxInstances: 10, minInstances: 1 }); + +// Role-based custom claims +import { onUserRoleCreated, onUserRoleDeleted } from "./userEvents"; +export { onUserRoleCreated as onUserRoleCreatedBRANCH_SUFFIX }; +export { onUserRoleDeleted as onUserRoleDeletedBRANCH_SUFFIX }; + +// Order counter +import { onOrderCreated, onOrderDelete, onOrderUpdate, archiveOrders, claimOrder, completeOrder, submitOrder } from "./ordersEvents"; +export { onOrderCreated as onOrderCreatedBRANCH_SUFFIX }; +export { onOrderDelete as onOrderDeleteBRANCH_SUFFIX }; +export { onOrderUpdate as onOrderUpdateBRANCH_SUFFIX }; +export { archiveOrders as archiveOrdersBRANCH_SUFFIX }; +export { claimOrder as claimOrderBRANCH_SUFFIX }; +export { completeOrder as completeOrderBRANCH_SUFFIX }; +export { submitOrder as submitOrderBRANCH_SUFFIX }; + +// Image generation is event driven, not arbitrary + +// Moderation +import { moderation } from "./moderation"; +export { moderation as moderationBRANCH_SUFFIX }; + +// Prompt reviser +// import { revisePrompt } from "./reviser"; +// export { revisePrompt as revisePrompt_BRANCH_SUFFIX }; + +import { taskGenerateImage } from "./tasks/generateImage"; +export { taskGenerateImage as taskGenerateImageBranchSuffix }; + +import { taskGenerateQuestions } from "./tasks/generateQuestions"; +export { taskGenerateQuestions as taskGenerateQuestionsBranchSuffix }; + +import { generateRevisedImages, rejectRevision, selectImage } from "./images"; +export { + generateRevisedImages as generateRevisedImagesBranchSuffix, + rejectRevision as rejectRevisionBranchSuffix, + selectImage as selectImageBranchSuffix +}; + +import { sendToPrinter } from "./sendToPrinter"; +export { sendToPrinter as sendToPrinterBRANCH_SUFFIX }; diff --git a/genlatte/firebase/functions/src/moderation.ts b/genlatte/firebase/functions/src/moderation.ts new file mode 100644 index 0000000..1240353 --- /dev/null +++ b/genlatte/firebase/functions/src/moderation.ts @@ -0,0 +1,105 @@ +import { onCall, HttpsError } from "firebase-functions/v2/https"; +import * as logger from "firebase-functions/logger"; +import { GoogleGenAI, HarmBlockThreshold, HarmCategory, ThinkingLevel } from '@google/genai'; +import { withRetry } from "./common"; +import { fetchRemoteConfigValues } from "./common/fetchRemoteConfig"; + +export const moderation = onCall(async (request) => { + let {name} = request.data; + if (!name) { + throw new HttpsError('invalid-argument', 'Missing name'); + } + + return word_moderation(name); + +}); + +export const word_moderation = (async (word_moderation: string, orderId?: string) => { + if (!word_moderation) { + throw new Error('Missing word_moderation'); + } + + try { + logger.info(`Moderating ${word_moderation}`); + + const projectId = process.env.GCP_PROJECT || process.env.GCLOUD_PROJECT; + const genAI = new GoogleGenAI({ vertexai: true, project: projectId, location: 'global' }); + + const { textModel, moderationPromptInstructions } = await fetchRemoteConfigValues(); + + const moderation = await stringModeration(word_moderation, genAI, textModel, moderationPromptInstructions, orderId); + + return { + moderation + }; + } catch (e: any) { + logger.error("Global error in generateImages", e); + // Safely return the error to the client + throw new HttpsError("internal", "An error occurred while generating images. Please try again later.", e.message); + } + +}); + +async function stringModeration(name: any, genAI: GoogleGenAI, textModel: string, moderationPromptInstructions: string, orderId?: string) { + + const prompt = `${moderationPromptInstructions}\n${name}\n`; + + const genConfig = { + maxOutputTokens: 65535, + temperature: 1, + topP: 0.95, + seed: 0, + thinkingConfig: { + thinkingLevel: ThinkingLevel.HIGH, + }, + safetySettings: [ + { + category: HarmCategory.HARM_CATEGORY_HATE_SPEECH, + threshold: HarmBlockThreshold.BLOCK_NONE, + }, + { + category: HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT, + threshold: HarmBlockThreshold.BLOCK_NONE, + }, + { + category: HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT, + threshold: HarmBlockThreshold.BLOCK_NONE, + }, + { + category: HarmCategory.HARM_CATEGORY_HARASSMENT, + threshold: HarmBlockThreshold.BLOCK_NONE, + } + ], + responseMimeType: 'application/json', + responseSchema: { + type: "OBJECT", + properties: { + isAllowed: { + type: "BOOLEAN", + }, + moderationReason: { + type: "STRING", + }, + }, + required: ["isAllowed", "moderationReason"], + }, + }; + + return await withRetry(async () => { + const chat = genAI.chats.create({ model: textModel, config: genConfig }); + const result = await chat.sendMessage({ message: prompt }); + if (!result.text) { + throw new Error("No result returned"); + } + + try { + return JSON.parse(result.text) as { isAllowed: boolean, moderationReason: string }; + } catch (e) { + logger.warn("Failed to parse moderation result. Falling back to default.", e); + return { + isAllowed: false, + moderationReason: "Failed to parse result", + }; + } + }, 3, 1000, orderId); +} diff --git a/genlatte/firebase/functions/src/ordersEvents/CONTEXT.md b/genlatte/firebase/functions/src/ordersEvents/CONTEXT.md new file mode 100644 index 0000000..a221b7e --- /dev/null +++ b/genlatte/firebase/functions/src/ordersEvents/CONTEXT.md @@ -0,0 +1,43 @@ +# Orders Events Module (`firebase/functions/src/ordersEvents/`) + +## Purpose +This directory contains the core business logic for the GenLatte order lifecycle. It manages order state transitions, metadata initialization, and backend operations triggered by either direct user actions (via Callables) or Firestore document lifecycle events (Triggers). + +## Detailed File Overviews + +- **`onOrderCreated.ts`**: + - **Description**: A Firestore trigger that fires when a new order is initialized. + - **Core Logic**: Sets the `expiresAt` timestamp for TTL-based cleanup and creates a corresponding entry in the `latteOrderMetadata` collection to track internal state and order sequencing. + +- **`onOrderUpdated.ts`**: + - **Description**: Orchestrates logic based on order status transitions. + - **Core Logic**: Monitors changes in the order state (e.g., transition to `submitted`) and performs downstream actions like notifying baristas or locking the order configuration. + +- **`submitOrder.ts`**: + - **Description**: A Callable function invoked by the kiosk when a user confirms their final order. + - **Core Logic**: Validates the selected image, locks the order against further changes, and marks it as ready in the barista queue. + +- **`claimOrder.ts` / `completeOrder.ts`**: + - **Description**: Barista-facing logic for managing the physical production of lattes. + - **Core Logic**: `claimOrder` assigns a barista to a specific request, and `completeOrder` marks the physical preparation as finished, allowing the order to move to the queue for pickup. + +- **`archiveOrders.ts`**: + - **Description**: Handles cleanup and historical archiving of finished or expired orders. + +## Dependencies/Relationships + +- **Firestore**: The primary source of truth for all order states. Logic extensively uses Firestore Transactions to ensure data consistency during claiming and completion. +- **Common Module**: Uses `./common.ts` for database naming conventions and TTL constants. +- **GenLatte Data Models**: Follows the schema defined in the shared data package to ensure consistency between frontend and backend. + +## Usage/Exports + +### Firestore Triggers +- `onOrderCreated`: Lifecycle hook for new records. +- `onOrderUpdated`: Lifecycle hook for state transitions. +- `onOrderDeleted`: Lifecycle hook for cleanup. + +### Callable Functions +- `submitOrder`: Finalizes customer ordering. +- `claimOrder`: Barista assignment. +- `completeOrder`: Finalizing preparation. diff --git a/genlatte/firebase/functions/src/ordersEvents/archiveOrders.ts b/genlatte/firebase/functions/src/ordersEvents/archiveOrders.ts new file mode 100644 index 0000000..29fe1da --- /dev/null +++ b/genlatte/firebase/functions/src/ordersEvents/archiveOrders.ts @@ -0,0 +1,35 @@ +import { onSchedule } from "firebase-functions/v2/scheduler"; +import { logger } from "firebase-functions/logger"; +import { db } from "../common"; + +export const archiveOrders = onSchedule("every 1 hours", async (event) => { + // Archival threshold. + const maxAgeInHours = 4; + const cutoff = new Date(Date.now() - maxAgeInHours * 60 * 60 * 1000); + + const snapshot = await db.collection("latteOrderMetadata") + .where("status", "==", "completed") + .where("completionTime", "<", cutoff) + .get(); + + if (snapshot.empty) { + logger.info("Did not find any new orders to archive"); + return; + } + + const docs = snapshot.docs; + const chunkSize = 500; + + for (let i = 0; i < docs.length; i += chunkSize) { + const batch = db.batch(); + const chunk = docs.slice(i, i + chunkSize); + + chunk.forEach((doc) => { + batch.update(doc.ref, { status: "archived" }); + }); + + await batch.commit(); + } + + logger.info(`Archived ${docs.length} orders`); +}); diff --git a/genlatte/firebase/functions/src/ordersEvents/claimOrder.ts b/genlatte/firebase/functions/src/ordersEvents/claimOrder.ts new file mode 100644 index 0000000..d092d57 --- /dev/null +++ b/genlatte/firebase/functions/src/ordersEvents/claimOrder.ts @@ -0,0 +1,33 @@ +import { onCall, HttpsError } from "firebase-functions/https"; +import { db } from "../common"; +import { logger } from "firebase-functions/logger"; + +export const claimOrder = onCall({ + +}, async (request) => { + const { orderId, baristaId } = request.data; + logger.info(`claimOrder called with orderId: ${orderId}`); + + if (!orderId || !baristaId) { + throw new HttpsError( + "invalid-argument", + "The 'orderId' and 'baristaId' arguments are required." + ); + } + + const latteOrderMetadataRef = db.collection("latteOrderMetadata").doc(orderId); + const docSnap = await latteOrderMetadataRef.get(); + + if (!docSnap.exists) { + throw new HttpsError("not-found", "Order not found."); + } + + if (docSnap.data()?.baristaId !== null) { + throw new HttpsError("failed-precondition", "Order is already claimed."); + } + + await latteOrderMetadataRef.update({ + status: "inProgress", + baristaId: baristaId, + }); +}); diff --git a/genlatte/firebase/functions/src/ordersEvents/common.ts b/genlatte/firebase/functions/src/ordersEvents/common.ts new file mode 100644 index 0000000..5059837 --- /dev/null +++ b/genlatte/firebase/functions/src/ordersEvents/common.ts @@ -0,0 +1,2 @@ +export const DATABASE_NAME = process.env.FUNCTIONS_EMULATOR === 'true' ? '(default)' : 'DATABASE_ID_PLACEHOLDER'; +export const SEVEN_DAYS_MILLIS = 7 * 24 * 60 * 60 * 1000; diff --git a/genlatte/firebase/functions/src/ordersEvents/completeOrder.ts b/genlatte/firebase/functions/src/ordersEvents/completeOrder.ts new file mode 100644 index 0000000..d9bb1ed --- /dev/null +++ b/genlatte/firebase/functions/src/ordersEvents/completeOrder.ts @@ -0,0 +1,114 @@ +import { FieldValue, Timestamp } from "firebase-admin/firestore"; +import { onCall, HttpsError } from "firebase-functions/https"; +import { logger } from "firebase-functions/logger"; +import { db } from "../common"; +import { SEVEN_DAYS_MILLIS } from "./common"; + +export const completeOrder = onCall({ + +}, async (request) => { + const { orderId, baristaId } = request.data; + logger.info(`completeOrder called with orderId: ${orderId}`); + + if (!orderId || !baristaId) { + throw new HttpsError( + "invalid-argument", + "The 'orderId' and 'baristaId' arguments are required." + ); + } + + const latteOrderRef = db.collection("latteOrders").doc(orderId); + const latteOrderMetadataRef = db.collection("latteOrderMetadata").doc(orderId); + + const orderDocSnap = await latteOrderRef.get(); + const metadataDocSnap = await latteOrderMetadataRef.get(); + + if (!metadataDocSnap.exists || !orderDocSnap.exists) { + throw new HttpsError("not-found", "Order not found."); + } + + const metadata = metadataDocSnap.data(); + const orderData = orderDocSnap.data(); + + if (!metadata) { + throw new HttpsError( + "not-found", + `Order metadata for ${orderId} not found.` + ); + } + + if (!orderData) { + throw new HttpsError( + "not-found", + `Order data for ${orderId} not found.` + ); + } + + if (baristaId != "moderator" && metadata.baristaId !== baristaId) { + throw new HttpsError( + "permission-denied", + `The provided baristaId ${baristaId} does not match the assigned barista for order ${orderId}.` + ); + } + + await latteOrderMetadataRef.update({ + status: "completed", + completionTime: FieldValue.serverTimestamp(), + }); + + await pushToRecentLatteImages(orderId, orderData, metadata); +}); + +/** + * Adds a completed latte order's image to the public recentLatteImages gallery + * if it has been approved. + */ +async function pushToRecentLatteImages( + orderId: string, + orderData: any, + metadata: any +) { + const imageBatchRef = db.collection("latteImageBatches").doc(metadata.imageBatchId); + const imageBatchSnap = await imageBatchRef.get(); + const imageBatchData = imageBatchSnap.data(); + + if (!imageBatchSnap.exists || !imageBatchData) { + logger.error( + `Image batch for ${orderId} not found. Cannot send to recentLatteImages. Also, wtf?` + ); + return; + } + + if (metadata.isImageApproved !== true) { + logger.error( + `Image for ${orderId} not approved. Not sending to recentLatteImages.` + ); + return; + } + + if (metadata.isNameApproved !== true) { + logger.error( + `Name for ${orderId} not approved. Not sending to recentLatteImages.` + ); + return; + } + + const fields = ["image0", "image1", "image2", "image3"]; + for (const field of fields) { + const image = imageBatchData[field]; + if (image.imageUrl === metadata.imageUrl) { + await db.collection("recentLatteImages").add({ + imageUrl: image.imageUrl, + prompt: image.prompt, + name: orderData.name, + happyPlace: orderData.happyPlace, + description: image.description, + createdAt: FieldValue.serverTimestamp(), + expireAt: Timestamp.fromMillis(Date.now() + SEVEN_DAYS_MILLIS), + }); + logger.info(`Order ${orderId} completed and image added to recentLatteImages`); + break; + } + } +} + diff --git a/genlatte/firebase/functions/src/ordersEvents/index.ts b/genlatte/firebase/functions/src/ordersEvents/index.ts new file mode 100644 index 0000000..cc30eb3 --- /dev/null +++ b/genlatte/firebase/functions/src/ordersEvents/index.ts @@ -0,0 +1,9 @@ +import { archiveOrders } from "./archiveOrders"; +import { claimOrder } from "./claimOrder"; +import { completeOrder } from "./completeOrder"; +import { onOrderCreated } from "./onOrderCreated"; +import { onOrderDelete } from "./onOrderDeleted"; +import { onOrderUpdate } from "./onOrderUpdated"; +import { submitOrder } from "./submitOrder"; + +export { onOrderCreated, onOrderDelete, onOrderUpdate, archiveOrders, claimOrder, completeOrder, submitOrder }; \ No newline at end of file diff --git a/genlatte/firebase/functions/src/ordersEvents/onOrderCreated.ts b/genlatte/firebase/functions/src/ordersEvents/onOrderCreated.ts new file mode 100644 index 0000000..c8005b1 --- /dev/null +++ b/genlatte/firebase/functions/src/ordersEvents/onOrderCreated.ts @@ -0,0 +1,49 @@ +import { getFirestore, Timestamp } from "firebase-admin/firestore"; +import { onDocumentCreated } from "firebase-functions/firestore"; +import { logger } from "firebase-functions/logger"; +import { DATABASE_NAME, SEVEN_DAYS_MILLIS } from "./common"; + +/** + * Triggered when a new order is created. + * Increments a daily counter and assigns an orderNumber to the order. + */ +export const onOrderCreated = onDocumentCreated( + { + document: "latteOrders/{orderId}", + database: DATABASE_NAME, + }, + async (event) => { + logger.info(`onOrderCreated called with orderId: ${event.params.orderId}`); + const db = getFirestore(DATABASE_NAME); + const orderRef = event.data?.ref; + + if (!orderRef) { + logger.error("No order reference found in event data."); + return; + } + + const orderRefId = event.data?.id; + if (!orderRefId) { + logger.error("No order reference ID found in event data."); + return; + } + + orderRef.update({ + expiresAt: Timestamp.fromMillis(Date.now() + SEVEN_DAYS_MILLIS) + }); + + const latteOrderMetadataRef = db.collection("latteOrderMetadata").doc(orderRefId); + + try { + await db.runTransaction(async (transaction) => { + transaction.create(latteOrderMetadataRef, { + expireAt: Timestamp.fromMillis(Date.now() + SEVEN_DAYS_MILLIS) + }); + }); + + logger.info( + `Successfully created latte order metadata for ${event.params.orderId} `); + } catch (error) { + logger.error(`Error processing order ${event.params.orderId}:`, error); + } + }); \ No newline at end of file diff --git a/genlatte/firebase/functions/src/ordersEvents/onOrderDeleted.ts b/genlatte/firebase/functions/src/ordersEvents/onOrderDeleted.ts new file mode 100644 index 0000000..a81a298 --- /dev/null +++ b/genlatte/firebase/functions/src/ordersEvents/onOrderDeleted.ts @@ -0,0 +1,30 @@ +import { getFirestore } from "firebase-admin/firestore"; +import { logger } from "firebase-functions/logger"; +import { onDocumentDeleted } from "firebase-functions/firestore"; +import { DATABASE_NAME } from "./common"; + +export const onOrderDelete = onDocumentDeleted( + { + document: "latteOrders/{orderId}", + database: DATABASE_NAME, + }, + async (event) => { + logger.info(`onOrderDelete called with orderId: ${event.params.orderId}`); + const db = getFirestore(DATABASE_NAME); + const orderId = event.params.orderId; + + console.log(`Document ${orderId} deleted from latteOrders. Cleaning up latteOrderMetadata...`); + + try { + // Reference the corresponding document in the other collection + const targetRef = db.collection("latteOrderMetadata").doc(orderId); + + // Perform the delete operation + await targetRef.delete(); + + console.log(`Successfully deleted matching document ${orderId} from latteOrderMetadata.`); + } catch (error) { + console.error(`Error deleting document ${orderId} from latteOrderMetadata:`, error); + } + +}); \ No newline at end of file diff --git a/genlatte/firebase/functions/src/ordersEvents/onOrderUpdated.ts b/genlatte/firebase/functions/src/ordersEvents/onOrderUpdated.ts new file mode 100644 index 0000000..59b7933 --- /dev/null +++ b/genlatte/firebase/functions/src/ordersEvents/onOrderUpdated.ts @@ -0,0 +1,93 @@ +import { FieldValue, Firestore, getFirestore, Timestamp } from "firebase-admin/firestore"; +import { onDocumentUpdated } from "firebase-functions/v2/firestore"; +import { logger } from "firebase-functions/logger"; +import { DATABASE_NAME, SEVEN_DAYS_MILLIS } from "./common"; +import { word_moderation } from "../moderation"; +import { generatePromptsForHappyPlace } from "../images"; +import { getFunctions } from "firebase-admin/functions"; + +export const onOrderUpdate = onDocumentUpdated( + { + document: "latteOrders/{orderId}", + database: DATABASE_NAME, + memory: "1GiB", + cpu: 1, + concurrency: 2, + minInstances: 1, + timeoutSeconds: 540, + }, + async (event) => { + logger.info(`onOrderUpdate called with orderId: ${event.params.orderId}`); + const db = getFirestore(DATABASE_NAME); + const orderBeforeUpdate = event.data?.before; + const orderAfterUpdate = event.data?.after; + + if(!orderBeforeUpdate?.exists){ + logger.info("Order created. Runs on create command listed above."); + return; + } + + if(!orderAfterUpdate){ + logger.info("Order deleted."); + db.collection("latteOrderMetadata").doc(event.params.orderId).delete(); + return; + } + + if (!orderAfterUpdate.exists) { + logger.error("No order reference found in event data."); + return; + } + + const latteOrderMetadataRef = db.collection("latteOrderMetadata").doc(orderAfterUpdate.ref.id); + if(orderBeforeUpdate?.data().happyPlace !== orderAfterUpdate.data().happyPlace){ + logger.info("Happy place updated."); + await latteOrderMetadataRef.update({ + isHappyPlaceApproved: null, + happyPlaceModerationReason: null + }); + } + const oldHappyPlace = orderBeforeUpdate?.data().happyPlace; + const happyPlace = orderAfterUpdate.data().happyPlace; + + if(happyPlace && happyPlace !== oldHappyPlace) { + const { generatePromptRequest, latteImageBatchDoc } = await createImageBatchDoc(db, happyPlace as string, event.params.orderId, orderAfterUpdate.ref.id); + for(let i = 0; i < generatePromptRequest.length; i++) { + getFunctions().taskQueue("taskGenerateImageBranchSuffix").enqueue({ + prompt: generatePromptRequest[i], + imageBatchId: latteImageBatchDoc.id, + index: i + }); + } + const {moderation} = await word_moderation(happyPlace as string, event.params.orderId); + + // We check whether the happy place has been changed under our hands. + // If so, we don't want to update the imageBatchId. + let currentOrderSnap = await orderAfterUpdate.ref.get(); + if (currentOrderSnap.data()?.happyPlace !== happyPlace) { + logger.info(`Order ${event.params.orderId} happy place changed during generation. Aborting.`); + } else { + if(!moderation.isAllowed) { + logger.info(`Order ${event.params.orderId} contains profane language.`); + } + + await latteOrderMetadataRef.update({ + isHappyPlaceApproved: moderation.isAllowed, + happyPlaceModerationReason: moderation.moderationReason, + ...(moderation.isAllowed && { imageBatchId: latteImageBatchDoc.id }) + }); + } + } + }); + +async function createImageBatchDoc(db: Firestore, happyPlace: string, eventOrderId: string, orderAfterUpdateId: string) { + const latteImageBatchesCollection = db.collection("latteImageBatches"); + const generatePromptRequest = await generatePromptsForHappyPlace(happyPlace, eventOrderId); + const latteImageBatchDoc = latteImageBatchesCollection.doc(); + await latteImageBatchDoc.create({ + orderId: orderAfterUpdateId, + createdAt: FieldValue.serverTimestamp(), + expireAt: Timestamp.fromMillis(Date.now() + SEVEN_DAYS_MILLIS), + }); + + return { generatePromptRequest, latteImageBatchDoc }; +} diff --git a/genlatte/firebase/functions/src/ordersEvents/submitOrder.ts b/genlatte/firebase/functions/src/ordersEvents/submitOrder.ts new file mode 100644 index 0000000..96c100d --- /dev/null +++ b/genlatte/firebase/functions/src/ordersEvents/submitOrder.ts @@ -0,0 +1,118 @@ +import { onCall, HttpsError } from "firebase-functions/https"; +import { DocumentReference, FieldValue, getFirestore } from "firebase-admin/firestore"; +import { db } from "../common"; +import { DATABASE_NAME } from "./common"; +import { logger } from "firebase-functions/logger"; +import { getRemoteConfig } from "firebase-admin/remote-config"; + +export const submitOrder = onCall({ + +}, async (request) => { + logger.info(`submitOrder called with data: ${JSON.stringify(request.data)}`); + const { orderId } = request.data; + + logger.info(`submitOrder called with orderId: ${orderId}`); + if (!orderId) { + throw new HttpsError( + "invalid-argument", + "The 'orderId' argument is required." + ); + } + + const latteOrderMetadataRef = db.collection("latteOrderMetadata").doc(orderId); + const latteOrderRef = db.collection("latteOrders").doc(orderId); + const metadataDocSnap = await latteOrderMetadataRef.get(); + const orderDocSnap = await latteOrderRef.get(); + + if (!metadataDocSnap.exists || !orderDocSnap.exists) { + throw new HttpsError("not-found", "Order not found."); + } + + const metadata = metadataDocSnap.data(); + const orderData = orderDocSnap.data(); + if (!metadata || !orderData) { + throw new HttpsError("not-found", `Order ${orderId} has empty data.`); + } + logger.info(`latteOrderMetadata data: ${JSON.stringify(metadata)}`); + + if(metadata.orderNumber === null || metadata.orderNumber === undefined) { + await assignOrderNumber(orderId, latteOrderMetadataRef); + } + + if (metadata.imageUrl === null) { + logger.info(`submitOrder missing imageUrl`); + throw new HttpsError( + "failed-precondition", + "Cannot submit order until an image has been selected." + ); + } + + await latteOrderMetadataRef.update({ + status: "submitted", + orderSubmittedTime: FieldValue.serverTimestamp(), + }); +}); + +const assignOrderNumber = async (orderId: string, latteOrderMetadataRef: DocumentReference) => { + const db = getFirestore(DATABASE_NAME); + const orderRef = db.collection("latteOrders").doc(orderId); + + if (!orderRef) { + logger.error("No order reference found in event data."); + return; + } + + // Format today's date as YYYY-MM-DD + const now = new Date(); + const dateId = now.toISOString().split("T")[0]; + + // Fetch NUM_SHARDS from Remote Config + // Control from remote config to limit the requirement + // to redeploy the function + const rc = getRemoteConfig(); + let NUM_SHARDS = 10; + try { + const template = await rc.getServerTemplate({ + defaultConfig: { + NUM_SHARDS: 10, + }, + }); + const config = template.evaluate(); + NUM_SHARDS = config.getNumber("NUM_SHARDS_BRANCH_SUFFIX"); + logger.info(`Using Remote Config NUM_SHARDS: ${NUM_SHARDS}`); + } catch (err) { + logger.warn( + "Failed to fetch Remote Config, using default NUM_SHARDS=10", err); + } + + const shardId = Math.floor(Math.random() * NUM_SHARDS); + const counterRef = db.collection("orderCount").doc(dateId) + .collection("shards").doc(shardId.toString()); + + try { + await db.runTransaction(async (transaction) => { + const counterDoc = await transaction.get(counterRef); + let currentShardCount = 0; + + if (counterDoc.exists) { + currentShardCount = counterDoc.data()?.count || 0; + } + + const newShardCount = currentShardCount + 1; + + // Calculate a unique order number across shards: + // (newCount - 1) * NUM_SHARDS + shardId + 1 + const orderNumber = (newShardCount - 1) * NUM_SHARDS + shardId + 1; + + transaction.set(counterRef, { count: newShardCount }, { merge: true }); + transaction.update(orderRef, { orderNumber: orderNumber }); + transaction.update(latteOrderMetadataRef, { orderNumber: orderNumber }); + }); + + logger.info( + `Successfully assigned orderNumber to ${orderId} ` + + `for date ${dateId} (Shard ${shardId})`); + } catch (error) { + logger.error(`Error processing order ${orderId}:`, error); + } +} \ No newline at end of file diff --git a/genlatte/firebase/functions/src/reviser/CONTEXT.md b/genlatte/firebase/functions/src/reviser/CONTEXT.md new file mode 100644 index 0000000..2472436 --- /dev/null +++ b/genlatte/firebase/functions/src/reviser/CONTEXT.md @@ -0,0 +1,33 @@ +# Reviser Module (`firebase/functions/src/reviser/`) + +## Purpose +This directory contains the logic for the "Image Revision" system. It uses multimodal LLMs (specifically Gemini 3 Flash) to analyze generated images and create interactive refinement questions for the user. This allows for a "human-in-the-loop" iterative design process. + +## Detailed File Overviews + +- **`reviseImageQuestionGen.ts`**: + - **Description**: The primary service for generating structured follow-up questions from an image. + - **Core Logic**: Orchestrates a multi-step AI workflow: + 1. Analyzes a base64-encoded image and generates a list of potential refinement questions. + 2. Post-processes the questions to enrich them with valid options (for multiple-choice) or min/max labels (for slider-based questions). + 3. Returns a structured JSON array suitable for frontend rendering. + +- **`reviserConfigs.ts`**: + - **Description**: Defines the JSON schema and configuration for GenAI models. + - **Core Logic**: Provides `GenerateContentConfig` objects that use constrained output formats to ensure Gemini returns valid, parseable JSON for questions, slider values, and text options. + +- **`reviserPrompts.ts`**: + - **Description**: Management of prompt templates used by the reviser. + - **Core Logic**: Defines the system instructions that guide the AI to focus on specific image attributes (color, composition, style) when proposing refinements. + +## Dependencies/Relationships + +- **Vertex AI (Gemini 3 Flash)**: Optimized for multimodal reasoning and fast structured output. +- **Common Module**: Uses `withRetry` from `../common` to handle the iterative nature of the AI service calls. +- **Tasks Module**: Invoked by `taskGenerateQuestions` in `../tasks/`. + +## Usage/Exports + +### Core Services +- `reviseImageQuestionGenerator`: The main entry point that takes an image and returned enriched question objects with unique IDs and metadata. +- `generateAnswersOrLabels`: Utility for secondary AI calls to populate question options. diff --git a/genlatte/firebase/functions/src/reviser/reviseImageQuestionGen.ts b/genlatte/firebase/functions/src/reviser/reviseImageQuestionGen.ts new file mode 100644 index 0000000..6f3d532 --- /dev/null +++ b/genlatte/firebase/functions/src/reviser/reviseImageQuestionGen.ts @@ -0,0 +1,121 @@ +import { HttpsError } from "firebase-functions/v2/https"; +import * as logger from "firebase-functions/logger"; +import { GenerateContentConfig, GoogleGenAI } from '@google/genai'; +import { withRetry } from "../common"; +import { fetchRemoteConfigValues } from "../common/fetchRemoteConfig"; +import { questionGenConfig, sliderValueGenConfig, textValueGenConfig } from "./reviserConfigs"; + +const MODEL = "gemini-3-flash-preview"; + +const getChat = (config?: GenerateContentConfig) => { + const projectId = process.env.GCP_PROJECT || process.env.GCLOUD_PROJECT; + const genAI = new GoogleGenAI({ vertexai: true, project: projectId, location: 'global' }); + const chat = genAI.chats.create({ model: MODEL, config: config }); + return chat; +} + +const generateQuestions = async(base64ImageFile: string, questionGenPromptInstructions: string, orderId?: string) => { + if (!base64ImageFile || typeof base64ImageFile !== 'string') { + return; + } + + try { + const chat = getChat(questionGenConfig); + + const content = [ + { + inlineData: { + mimeType: "image/png", + data: base64ImageFile, + }, + }, + {text: questionGenPromptInstructions} + ] + + return await withRetry(async () => { + logger.info("Generating questions with image "); + const result = await chat.sendMessage({ message: content }); + if (!result.text) { + logger.error("no text returned from Gemini"); + throw new Error("No text returned from Gemini"); + } + + try { + logger.info("response from Gemini: ", result.text); + const questions = JSON.parse(result.text); + return questions; + } catch (e) { + logger.warn("Failed to parse JSON response from Gemini, attempting fallback", e); + throw new Error("Invalid output format from GenAI"); + } + }, 3, 1000, orderId); + + } catch (e: any) { + logger.error("Error in generateQuestions function", e); + throw new HttpsError("internal", "An error occurred while generating revisions.", e.message); + } +} + +export const generateAnswersOrLabels = async(promptWithQuestionEmbedded: string, config: any, orderId?: string) => { + if (!promptWithQuestionEmbedded || typeof promptWithQuestionEmbedded !== 'string') { + return; + } + + try { + const chat = getChat(config); + + const content = [ + {text: promptWithQuestionEmbedded} + ] + + return await withRetry(async () => { + const result = await chat.sendMessage({ message: content }); + if (!result.text) { + logger.error("no text returned from Gemini"); + throw new Error("No text returned from Gemini"); + } + + try { + logger.info("response from Gemini: ", result.text); + const questions = JSON.parse(result.text); + return questions; + } catch (e) { + logger.warn("Failed to parse JSON response from Gemini, attempting fallback", e); + throw new Error("Invalid output format from GenAI"); + } + }, 3, 1000, orderId); + } catch (e: any) { + logger.error("Error in generateAnswersOrLabels function", e); + throw new HttpsError("internal", "An error occurred while generating revisions.", e.message); + } +} + +export const reviseImageQuestionGenerator = async (base64ImageFile: string, orderId?: string) => { + if (!base64ImageFile || typeof base64ImageFile !== 'string') { + return; + } + + const { questionGenPromptInstructions, sliderValueGenPromptInstructions, textValueGenPromptInstructions } = await fetchRemoteConfigValues(); + + const questions = await generateQuestions(base64ImageFile, questionGenPromptInstructions, orderId); + const questionsWithValues = await Promise.all(questions.map(async (question: any) => { + let fullQuestion = question; + if (question.type === "multipleChoiceQuestion") { + logger.info("Generating acceptable answers for question: ", question.question); + const prompt = textValueGenPromptInstructions.replace("{{userSuppliedQuestion}}", question.question); + fullQuestion.acceptableAnswers = await generateAnswersOrLabels(prompt, textValueGenConfig, orderId); + } + if (question.type === "zeroToOneQuestion" || question.type === "negativeOneToOneQuestion") { + logger.info("Generating slider values for question: ", question.question); + const prompt = sliderValueGenPromptInstructions.replace("{{userSuppliedQuestion}}", question.question); + const sliderValues = await generateAnswersOrLabels(prompt, sliderValueGenConfig, orderId); + fullQuestion.minValueLabel = sliderValues.minValueLabel; + fullQuestion.maxValueLabel = sliderValues.maxValueLabel; + } + return fullQuestion; + })); + + logger.info("questionsWithValues: ", questionsWithValues); + console.log("questionsWithValues: ", questionsWithValues); + return questionsWithValues; +} diff --git a/genlatte/firebase/functions/src/reviser/reviserConfigs.ts b/genlatte/firebase/functions/src/reviser/reviserConfigs.ts new file mode 100644 index 0000000..19e0b83 --- /dev/null +++ b/genlatte/firebase/functions/src/reviser/reviserConfigs.ts @@ -0,0 +1,117 @@ +import { HarmBlockThreshold, HarmCategory } from "@google/genai"; + +export const questionGenConfig = { + maxOutputTokens: 65535, + temperature: 0.7, + topP: 0.95, + safetySettings: [ + { + category: HarmCategory.HARM_CATEGORY_HATE_SPEECH, + threshold: HarmBlockThreshold.BLOCK_NONE, + }, + { + category: HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT, + threshold: HarmBlockThreshold.BLOCK_NONE, + }, + { + category: HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT, + threshold: HarmBlockThreshold.BLOCK_NONE, + }, + { + category: HarmCategory.HARM_CATEGORY_HARASSMENT, + threshold: HarmBlockThreshold.BLOCK_NONE, + } + ], + responseMimeType: 'application/json', + responseSchema: { + type: "ARRAY", + description: "A list of 2 to 4 questions to tweak the supplied image.", + items: { + type: "OBJECT", + properties: { + question: { + type: "STRING", + description: "The question to ask the user to refine their prompt." + }, + type: { + type: "STRING", + description: "The type of answer expected. Must be one of: 'multipleChoiceQuestion', 'zeroToOneQuestion', 'negativeOneToOneQuestion', 'textQuestion'" + }, + }, + required: ["question", "type"] + }, + }, + }; + +export const sliderValueGenConfig = { + maxOutputTokens: 65535, + temperature: 0.7, + topP: 0.95, + safetySettings: [ + { + category: HarmCategory.HARM_CATEGORY_HATE_SPEECH, + threshold: HarmBlockThreshold.BLOCK_NONE, + }, + { + category: HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT, + threshold: HarmBlockThreshold.BLOCK_NONE, + }, + { + category: HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT, + threshold: HarmBlockThreshold.BLOCK_NONE, + }, + { + category: HarmCategory.HARM_CATEGORY_HARASSMENT, + threshold: HarmBlockThreshold.BLOCK_NONE, + } + ], + responseMimeType: 'application/json', + responseSchema: { + type: "OBJECT", + properties: { + minValueLabel: { + type: "STRING", + description: "The label to display for the minimum value of the slider. Only used for 'zeroToOneQuestion' and 'negativeOneToOneQuestion'" + }, + maxValueLabel: { + type: "STRING", + description: "The label to display for the maximum value of the slider. Only used for 'zeroToOneQuestion' and 'negativeOneToOneQuestion'" + }, + }, + required: ["minValueLabel", "maxValueLabel"] + }, + }; + +export const textValueGenConfig = { + maxOutputTokens: 65535, + temperature: 0.7, + topP: 0.95, + safetySettings: [ + { + category: HarmCategory.HARM_CATEGORY_HATE_SPEECH, + threshold: HarmBlockThreshold.BLOCK_NONE, + }, + { + category: HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT, + threshold: HarmBlockThreshold.BLOCK_NONE, + }, + { + category: HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT, + threshold: HarmBlockThreshold.BLOCK_NONE, + }, + { + category: HarmCategory.HARM_CATEGORY_HARASSMENT, + threshold: HarmBlockThreshold.BLOCK_NONE, + } + ], + responseMimeType: 'application/json', + responseSchema: { + type: "ARRAY", + description: "A list of 2 to 4 acceptable answers based on the supplied question", + items: { + type: "STRING", + description: "A single acceptable answer based on the supplied question" + }, + }, + }; + \ No newline at end of file diff --git a/genlatte/firebase/functions/src/sendToPrinter.ts b/genlatte/firebase/functions/src/sendToPrinter.ts new file mode 100644 index 0000000..8635e31 --- /dev/null +++ b/genlatte/firebase/functions/src/sendToPrinter.ts @@ -0,0 +1,87 @@ +import { onCall, HttpsError } from "firebase-functions/v2/https"; +import sharp from 'sharp'; +import { db } from "./common"; +import { logger } from "firebase-functions"; +const headers = { + "Redacted": "headers", +}; + +async function sendUploadMessageCode(imageBufferRaw: Buffer, machineId: string, blackAndWhiteMachine: boolean = true) { + try { + const image = await sharp(imageBufferRaw) + .resize(800, 800); + + if (blackAndWhiteMachine) { + image.grayscale(); + } + + const imageBuffer = await image.jpeg().toBuffer(); + + const base64Image = imageBuffer.toString('base64'); + + const payload = `redacted`; + + const response = await fetch("redacted_url", { + method: "POST", + headers: { + ...headers, + "Content-Length": Buffer.byteLength(payload, 'utf8').toString() + }, + body: payload + }); + + const data = await response.text(); + console.log("Response status:", response.status); + console.log("Response data:", data); + } catch (error) { + console.error("Error sending request:", error); + } +} + +/** + * Looks up a machine by name. + * @param machineName The name of the machine to look up. + * @returns The machine ID and whether it is a black and white machine. + */ +const machineLookupByName = async (machineName: string): Promise<{ id: string, isBlackAndWhite: boolean }> => { + const machineRef = db.collection('machines').where('name', '==', machineName); + const snapshot = await machineRef.get(); + if (snapshot.empty) { + throw new HttpsError("not-found", "Machine not found."); + } + return { id: snapshot.docs[0].id, isBlackAndWhite: snapshot.docs[0].data().isBlackAndWhite }; +} + +/** + * Sends an image to the printer. + * @param imagePath The gs:// path to the image to send. + */ +export const sendToPrinter = onCall({ memory: "1GiB" }, async (request) => { + logger.info("Request data:", JSON.stringify(request.data)); + let { imagePath, machineName } = request.data; + + if (!machineName || typeof machineName !== 'string') { + machineName = "Barry White"; + } + + if (!imagePath || typeof imagePath !== 'string' || !imagePath.startsWith('https://')) { + throw new HttpsError("invalid-argument", "The function must be called with a valid 'imagePath' string argument starting with 'https://'."); + } + + const machine = await machineLookupByName(machineName); + logger.info("Machine found:", JSON.stringify(machine)); + + try { + const url = new URL(imagePath); + + const imageBufferRaw = await fetch(url.toString()).then(res => res.arrayBuffer()); + + const machineIdInt = parseInt(machine.id); + const magicNumber = machineIdInt ^ 21937163; + + await sendUploadMessageCode(Buffer.from(imageBufferRaw), magicNumber.toString(), machine.isBlackAndWhite); + return { success: true }; + } catch (e: any) { + throw new HttpsError("internal", "An error occurred while sending the image to the printer.", e.message); + } +}); diff --git a/genlatte/firebase/functions/src/tasks/CONTEXT.md b/genlatte/firebase/functions/src/tasks/CONTEXT.md new file mode 100644 index 0000000..2aea412 --- /dev/null +++ b/genlatte/firebase/functions/src/tasks/CONTEXT.md @@ -0,0 +1,37 @@ +# Tasks Module (`firebase/functions/src/tasks/`) + +## Purpose +This directory contains Firebase Cloud Task handlers used for reliable, long-running background processing. By offloading resource-intensive generative AI operations (image and question generation) to Cloud Tasks, the system ensures higher reliability with built-in retry logic and configurable timeouts. + +## Detailed File Overviews + +- **`generateImage.ts`**: + - **Description**: Handles requested image generation tasks. + - **Core Logic**: + - Retrieves order and batch context from Firestore. + - Calls the image generation engine (`generateImageWithPro`) to produce a base64 image via Vertex AI. + - Persists the resulting image to Firebase Storage and makes it public. + - Generates a text description of the image content. + - Updates the `latteImageBatch` document with the new image URL and metadata. + - Enqueues a subsequent `taskGenerateQuestions` for initial generations. + +- **`generateQuestions.ts`**: + - **Description**: Orchestrates the generation of AI-driven follow-up questions for image refinement. + - **Core Logic**: + - Downloads the generated image from Storage. + - Invokes the `reviseImageQuestionGenerator` to analyze the image and generate context-aware questions. + - Updates the Firestore batch record with the generated questions, assigning unique IDs to each for client-side tracking. + +## Dependencies/Relationships + +- **Vertex AI / Gemini**: Indirectly utilized via internal service wrappers in `../images` and `../reviser`. +- **Firebase Admin SDK**: Used for Firestore updates (`admin/firestore`), Storage management (`admin/storage`), and enqueuing further tasks (`admin/functions`). +- **Cloud Task Queue**: These handlers are triggered by the Cloud Task infrastructure based on queue names defined in the service configuration. + +## Usage/Exports + +### Cloud Task Handlers +- `taskGenerateImage`: Background worker for image creation. +- `taskGenerateQuestions`: Background worker for image analysis and question generation. + +These functions use standard `onTaskDispatched` configurations with a retry limit of 5 attempts and a 540-second timeout to accommodate generative AI latency. diff --git a/genlatte/firebase/functions/src/tasks/generateImage.ts b/genlatte/firebase/functions/src/tasks/generateImage.ts new file mode 100644 index 0000000..3a6ccff --- /dev/null +++ b/genlatte/firebase/functions/src/tasks/generateImage.ts @@ -0,0 +1,60 @@ +import { onTaskDispatched } from "firebase-functions/tasks"; +import { generateDescription, generateImageWithPro } from "../images"; +import { getStorage } from "firebase-admin/storage"; +import { getFirestore } from "firebase-admin/firestore"; +import { getFunctions } from "firebase-admin/functions"; +import { logger } from "firebase-functions/logger"; +import { fetchRemoteConfigValues } from "../common/fetchRemoteConfig"; + +const DATABASE_NAME = process.env.FUNCTIONS_EMULATOR === 'true' ? '(default)' : 'DATABASE_ID_PLACEHOLDER'; +const db = getFirestore(DATABASE_NAME); + +export const taskGenerateImage = onTaskDispatched({ + retryConfig: { + maxAttempts: 5, + minBackoffSeconds: 1, + maxBackoffSeconds: 20, + }, + concurrency: 1, + minInstances: 8, + maxInstances: 80, + memory: "8GiB", + timeoutSeconds: 540, +}, async (request) => { + const { prompt, imageBatchId, index, isRevision = false } = request.data; + const latteImageBatchDoc = db.collection("latteImageBatches").doc(imageBatchId); + const latteImageBatchData = await latteImageBatchDoc.get(); + const orderId = latteImageBatchData.data()?.orderId; + logger.info(`taskGenerateImage called for orderId: ${orderId}`); + + const imagePath = `latteImages/${imageBatchId}/${index}.png`; + // run multiple of these using the remote config value AIR_CONDITIONING_QUOTENT and take the first + // result for the image + const {imageParallelism} = await fetchRemoteConfigValues(); + const generateImagesPromises = []; + for(let i = 0; i < imageParallelism; i++) { + generateImagesPromises.push(generateImageWithPro(prompt, orderId)); + } + const result = await Promise.any(generateImagesPromises); + const storageImage = getStorage().bucket().file(imagePath); + const base64String = result.image.split(',')[1]; + const imageBuffer = Buffer.from(base64String, 'base64'); + await storageImage.save(imageBuffer, { contentType: "image/png" }); + await storageImage.makePublic(); + const imageUrl = await storageImage.publicUrl(); + await latteImageBatchDoc.update({ + [`image${index}`]: { + id: crypto.randomUUID(), + imageUrl: imageUrl, + prompt: result.prompt, + description: await generateDescription(base64String, orderId), + } + }); + if(!isRevision) { + await getFunctions().taskQueue("taskGenerateQuestionsBranchSuffix").enqueue({ + imageBatchId: imageBatchId, + index: index, + storageImagePath: imagePath, + }); + } +}); \ No newline at end of file diff --git a/genlatte/firebase/functions/src/tasks/generateQuestions.ts b/genlatte/firebase/functions/src/tasks/generateQuestions.ts new file mode 100644 index 0000000..f90b0bb --- /dev/null +++ b/genlatte/firebase/functions/src/tasks/generateQuestions.ts @@ -0,0 +1,41 @@ +import { onTaskDispatched } from "firebase-functions/tasks"; +import { getStorage } from "firebase-admin/storage"; +import { getFirestore } from "firebase-admin/firestore"; +import { reviseImageQuestionGenerator } from "../reviser/reviseImageQuestionGen"; +import { logger } from "firebase-functions/logger"; + +const DATABASE_NAME = process.env.FUNCTIONS_EMULATOR === 'true' ? '(default)' : 'DATABASE_ID_PLACEHOLDER'; +const db = getFirestore(DATABASE_NAME); + +export const taskGenerateQuestions = onTaskDispatched({ + retryConfig: { + maxAttempts: 5, + minBackoffSeconds: 1, + maxBackoffSeconds: 20, + }, + memory: "8GiB", + timeoutSeconds: 540, +}, async (request) => { + const { imageBatchId, index, storageImagePath } = request.data; + const latteImageBatchDoc = db.collection("latteImageBatches").doc(imageBatchId); + const latteImageBatchData = await latteImageBatchDoc.get(); + const orderId = latteImageBatchData.data()?.orderId; + logger.info(`taskGenerateQuestions called for orderId: ${orderId}`); + + const storageImage = getStorage().bucket().file(storageImagePath); + const [imageBuffer] = await storageImage.download(); + const base64String = imageBuffer.toString('base64'); + + const questions = await reviseImageQuestionGenerator(base64String, orderId); + console.log(questions); + logger.info(questions); + if (questions) { + const questionsWithIds = questions.map((q: any) => ({ + ...q, + id: crypto.randomUUID() + })); + await latteImageBatchDoc.update({ + [`image${index}.questions`]: questionsWithIds, + }); + } +}); \ No newline at end of file diff --git a/genlatte/firebase/functions/src/userEvents/CONTEXT.md b/genlatte/firebase/functions/src/userEvents/CONTEXT.md new file mode 100644 index 0000000..bd045d2 --- /dev/null +++ b/genlatte/firebase/functions/src/userEvents/CONTEXT.md @@ -0,0 +1,13 @@ +# User Event Functions + +**Purpose:** +Provides administrative logic handling Identity, Authentication, and Role-Based claims allocation. + +**Detailed File Overviews:** + +- `claim.ts`: + - **Description**: User creation interceptor. + - **Core Logic**: Listens at endpoints for new user auth signups. Parses metadata, verifies admin keys or internal configurations, and assigns Custom User Claims (e.g., `moderator: true`, `kiosk: true`) using `admin.auth().setCustomUserClaims()` which authorizes access boundaries later inside the `genlatte-ui` routing layer. + +**Dependencies/Relationships:** +- Determines the data boundary utilized actively by `genlatte-ui/lib/src/core/auth/auth_bloc.dart`. diff --git a/genlatte/firebase/functions/src/userEvents/claim.ts b/genlatte/firebase/functions/src/userEvents/claim.ts new file mode 100644 index 0000000..5f717cd --- /dev/null +++ b/genlatte/firebase/functions/src/userEvents/claim.ts @@ -0,0 +1,63 @@ +import { + onDocumentCreated, + onDocumentDeleted, +} from "firebase-functions/v2/firestore"; +import {getAuth} from "firebase-admin/auth"; +import * as logger from "firebase-functions/logger"; + +/** + * Triggered when a document is created at roles/{role}/users/{uid}. + * Grants the specified custom claim to the user. + */ +export const onUserRoleCreated = onDocumentCreated( + { + document: "roles/{role}/users/{uid}", + database: "DATABASE_ID_PLACEHOLDER", + }, + async (event) => { + const {role, uid} = event.params; + + try { + const auth = getAuth(); + const user = await auth.getUser(uid); + const existingClaims = user.customClaims || {}; + + const newClaims = {...existingClaims, [role]: true}; + await auth.setCustomUserClaims(uid, newClaims); + + logger.info(`Granted role '${role}' to user ${uid}`); + } catch (error) { + logger.error(`Error granting role '${role}' to user ${uid}:`, error); + } + }); + +/** + * Triggered when a document is deleted at roles/{role}/users/{uid}. + * Removes the specified custom claim from the user. + */ +export const onUserRoleDeleted = onDocumentDeleted( + { + document: "roles/{role}/users/{uid}", + database: "DATABASE_ID_PLACEHOLDER", + }, + async (event) => { + const {role, uid} = event.params; + + try { + const auth = getAuth(); + const user = await auth.getUser(uid); + const existingClaims = user.customClaims || {}; + + if (role in existingClaims) { + const newClaims = {...existingClaims}; + delete newClaims[role]; + await auth.setCustomUserClaims(uid, newClaims); + logger.info(`Removed role '${role}' from user ${uid}`); + } else { + logger.info( + `User ${uid} did not have role '${role}'. No action taken.`); + } + } catch (error) { + logger.error(`Error removing role '${role}' from user ${uid}:`, error); + } + }); diff --git a/genlatte/firebase/functions/src/userEvents/index.ts b/genlatte/firebase/functions/src/userEvents/index.ts new file mode 100644 index 0000000..6bdcdf1 --- /dev/null +++ b/genlatte/firebase/functions/src/userEvents/index.ts @@ -0,0 +1,2 @@ +import { onUserRoleCreated, onUserRoleDeleted } from "./claim"; +export { onUserRoleCreated, onUserRoleDeleted }; diff --git a/genlatte/firebase/functions/tsconfig.dev.json b/genlatte/firebase/functions/tsconfig.dev.json new file mode 100644 index 0000000..3d85e61 --- /dev/null +++ b/genlatte/firebase/functions/tsconfig.dev.json @@ -0,0 +1,5 @@ +{ + "include": [ + // ".eslintrc.js" + ] +} \ No newline at end of file diff --git a/genlatte/firebase/functions/tsconfig.json b/genlatte/firebase/functions/tsconfig.json new file mode 100644 index 0000000..b85fa69 --- /dev/null +++ b/genlatte/firebase/functions/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "module": "NodeNext", + "esModuleInterop": true, + "moduleResolution": "nodenext", + "noImplicitReturns": true, + "noUnusedLocals": true, + "outDir": "lib", + "sourceMap": true, + "strict": true, + "skipLibCheck": true, + "target": "es2021" + }, + "compileOnSave": true, + "include": [ + "src" + ] +} \ No newline at end of file diff --git a/genlatte/firebase/inject_prompts.js b/genlatte/firebase/inject_prompts.js new file mode 100644 index 0000000..895a290 --- /dev/null +++ b/genlatte/firebase/inject_prompts.js @@ -0,0 +1,60 @@ +const fs = require('fs'); +const path = require('path'); + +const PROMPTS_DIR = path.join(__dirname, 'functions/src/common/prompts'); +const REMOTE_CONFIG_PATH = path.join(__dirname, 'remoteconfig.json'); + +function extractStringFromTs(content) { + const firstTick = content.indexOf('`'); + const lastTick = content.lastIndexOf('`'); + if (firstTick === -1 || lastTick === -1 || firstTick === lastTick) { + return content; + } + return content.substring(firstTick + 1, lastTick); +} + +function main() { + if (!fs.existsSync(REMOTE_CONFIG_PATH)) { + console.error(`Error: ${REMOTE_CONFIG_PATH} not found.`); + process.exit(1); + } + + const configRaw = fs.readFileSync(REMOTE_CONFIG_PATH, 'utf8'); + let config; + try { + config = JSON.parse(configRaw); + } catch (e) { + console.error(`Error parsing JSON in ${REMOTE_CONFIG_PATH}: ${e}`); + process.exit(1); + } + + const promptMappings = { + 'MODERATION_PROMPT_BRANCH_SUFFIX': 'moderation.ts', + 'POWERUP_PROMPT_BRANCH_SUFFIX': 'powerup.ts', + 'DESCRIPTION_PROMPT_BRANCH_SUFFIX': 'description.ts', + 'QUESTION_GEN_PROMPT_BRANCH_SUFFIX': 'reviserQuestion.ts', + 'SLIDER_VALUE_GEN_PROMPT_BRANCH_SUFFIX': 'reviserSlider.ts', + 'TEXT_VALUE_GEN_PROMPT_BRANCH_SUFFIX': 'reviserText.ts' + }; + + for (const [configKey, promptFilename] of Object.entries(promptMappings)) { + const promptPath = path.join(PROMPTS_DIR, promptFilename); + if (fs.existsSync(promptPath)) { + const rawContent = fs.readFileSync(promptPath, 'utf8'); + const promptContent = promptFilename.endsWith('.ts') ? extractStringFromTs(rawContent) : rawContent; + + if (config.parameters && config.parameters[configKey]) { + config.parameters[configKey].defaultValue.value = promptContent; + console.log(`Injected ${promptFilename} into ${configKey}`); + } else { + console.warn(`Warning: ${configKey} not found in remote config parameters.`); + } + } else { + console.warn(`Warning: ${promptPath} not found.`); + } + } + + fs.writeFileSync(REMOTE_CONFIG_PATH, JSON.stringify(config, null, 4)); +} + +main(); diff --git a/genlatte/firebase/merge_remote_config.js b/genlatte/firebase/merge_remote_config.js new file mode 100644 index 0000000..1110386 --- /dev/null +++ b/genlatte/firebase/merge_remote_config.js @@ -0,0 +1,38 @@ +const fs = require('fs'); + +/** + * Merges a local Remote Config template into a server-side template. + * Overwrites server-side parameters with local ones, but preserves + * server-side parameters that are not in the local file. + */ +function mergeTemplates(serverPath, localPath) { + try { + const serverTemplate = JSON.parse(fs.readFileSync(serverPath, 'utf8')); + const localTemplate = JSON.parse(fs.readFileSync(localPath, 'utf8')); + + // Ensure parameters objects exist + serverTemplate.parameters = serverTemplate.parameters || {}; + const localParameters = localTemplate.parameters || {}; + + // Merge parameters + Object.keys(localParameters).forEach(key => { + console.log(`Overriding/Adding parameter: ${key}`); + serverTemplate.parameters[key] = localParameters[key]; + }); + + // Write back to serverPath + fs.writeFileSync(serverPath, JSON.stringify(serverTemplate, null, 2)); + console.log('Successfully merged templates.'); + } catch (error) { + console.error('Error merging templates:', error.message); + process.exit(1); + } +} + +const args = process.argv.slice(2); +if (args.length < 2) { + console.error('Usage: node merge_remote_config.js '); + process.exit(1); +} + +mergeTemplates(args[0], args[1]); diff --git a/genlatte/firebase/package.json b/genlatte/firebase/package.json new file mode 100644 index 0000000..ccc7f65 --- /dev/null +++ b/genlatte/firebase/package.json @@ -0,0 +1,20 @@ +{ + "name": "firebase", + "version": "1.0.0", + "description": "", + "main": "create_users.js", + "scripts": { + "test": "firebase emulators:exec --project demo-latteart-rules --only firestore 'mocha tests/**/*.test.js'" + }, + "keywords": [], + "author": "", + "license": "ISC", + "type": "commonjs", + "devDependencies": { + "@firebase/rules-unit-testing": "^5.0.0", + "firebase": "^12.10.0", + "firebase-admin": "^13.7.0", + "firebase-tools": "^15.10.1", + "mocha": "^11.7.5" + } +} diff --git a/genlatte/firebase/remoteconfig.json b/genlatte/firebase/remoteconfig.json new file mode 100644 index 0000000..3ef02ad --- /dev/null +++ b/genlatte/firebase/remoteconfig.json @@ -0,0 +1,74 @@ +{ + "parameters": { + "IMAGE_PARALLELISM": { + "defaultValue": { + "value": "4" + }, + "valueType": "NUMBER", + "description": "How many images we should be generating at once." + }, + "NUM_SHARDS_BRANCH_SUFFIX": { + "defaultValue": { + "value": "1" + }, + "valueType": "NUMBER", + "description": "Number of shards for the order counter to avoid contention." + }, + "MODERATION_PROMPT_BRANCH_SUFFIX": { + "defaultValue": { + "value": "You are an expert content moderation bot.\n\n**TASK**\nYour goal is to determine if a given string of text is inappropriate to be used as a username or \"Happy Place\".\n\n**CRITERIA FOR INAPPROPRIATE CONTENT**\nA string is considered inappropriate if it contains:\n1. **Profanity:** Obscene or vulgar language.\n2. **Hate Speech:** Attacks on individuals or groups based on identity.\n3. **Glorification of Violence:** Promoting or celebrating violent acts.\n4. **Trivialization of Tragedy:** Using historical atrocities or disasters in a trivial context (e.g., \"1945 Hiroshima\" as a happy place).\n5. **Contemporary issues:** Referencing current news cycle items, political figures, polarizing stories, etc\n\n**INSTRUCTIONS**\n- Analyze the input text.\n- If the text meets any of the criteria above, it is NOT allowed. Set `isAllowed` to `false` and provide a `moderationReason`.\n- If the text is acceptable, it IS allowed. Set `isAllowed` to `true` and provide an empty `moderationReason`.\n- Your output must be valid JSON matching the specified schema.\n\n**EXAMPLES**\n\nTEXT TO MODERATE: ASSMAN\n{\"isAccepted\": false, \"reason\": \"contains profane language\"}\n\nTEXT TO MODERATE: 1945 Hiroshima\n{\"isAccepted\": false, \"reason\": \"references historical tragedy\"}\n\nTEXT TO MODERATE: ground zero\n{\"isAccepted\": false, \"reason\": \"references historical tragedy\"}\n\nTEXT TO MODERATE: The beach at sunset\n{\"isAccepted\": true, \"reason\": null}\n\nTEXT TO MODERATE: White Power\n{\"isAccepted\": false, \"reason\": \"contains hate speech\"}\n\nTEXT TO MODERATE: Ted Bundy Fan\n{\"isAccepted\": false, \"reason\": \"references a historical crime\"}\n\nTEXT TO MODERATE: A pool party\n{\"isAccepted\": true, \"reason\": null}\n\nTEXT TO MODERATE: A little kids' pool party\n{\"isAccepted\": false, \"reason\": \"references kids\"}\n\nTEXT TO MODERATE: A high school football game\n{\"isAccepted\": false, \"reason\": \"references high school\"}\n\nTEXT TO MODERATE: A middle school choir concert\n{\"isAccepted\": false, \"reason\": \"references middle school\"}\n\nTEXT TO MODERATE: An elementary school play\n{\"isAccepted\": false, \"reason\": \"references elementary school\"}\n\nTEXT TO MODERATE: A Michigan football game\n{\"isAccepted\": true, \"reason\": null}\n\nTEXT TO MODERATE: A Real Madrid match\n{\"isAccepted\": true, \"reason\": null}\n\nTEXT TO MODEATE: ice\n{\"isAccepted\": false, \"reason\": \"contemporary hot topic\"}\n\nTEXT TO MODERATE: Vietnam\n{\"isAccepted\": true, \"reason\": null}\n\nTEXT TO MODERATE: Vietnam War\n{\"isAccepted\": false, \"reason\": \"references historical tragedy\"}\n\nTEXT TO MODERATE: Japan\n{\"isAccepted\": true, \"reason\": null}\n\nTEXT TO MODERATE: Imperial Japan\n{\"isAccepted\": false, \"reason\": \"references historical tragedy\"}\n\nTEXT TO MODERATE: Germany\n{\"isAccepted\": true, \"reason\": null}\n\nTEXT TO MODERATE: Nazi Germany\n{\"isAccepted\": false, \"reason\": \"references historical tragedy\"}\n\nTEXT TO MODERATE: Korea\n{\"isAccepted\": true, \"reason\": null}\n\nTEXT TO MODERATE: South Korea\n{\"isAccepted\": true, \"reason\": null}\n\nTEXT TO MODERATE: Korean War\n{\"isAccepted\": false, \"reason\": \"references historical tragedy\"}\n\nTEXT TO MODERATE: North Korea\n{\"isAccepted\": false, \"reason\": \"references contemporary geopolitical conflict\"}\n\nTEXT TO MODERATE: Iran\n{\"isAccepted\": false, \"reason\": \"references ongoing geopolitical conflict\"}\n\nTEXT TO MODERATE: Kyiv\n{\"isAccepted\": true, \"reason\": null}\n---\n\nTEXT TO MODERATE:" + }, + "valueType": "STRING", + "description": "The prompt used to moderate free form text input for explicit content" + }, + "DESCRIPTION_PROMPT_BRANCH_SUFFIX": { + "defaultValue": { + "value": "" + }, + "valueType": "STRING", + "description": "Prompt for describing an image." + }, + "QUESTION_GEN_PROMPT_BRANCH_SUFFIX": { + "defaultValue": { + "value": "" + }, + "valueType": "STRING", + "description": "Prompt for revising image questions." + }, + "SLIDER_VALUE_GEN_PROMPT_BRANCH_SUFFIX": { + "defaultValue": { + "value": "" + }, + "valueType": "STRING", + "description": "Prompt for generating slider min/max values." + }, + "TEXT_VALUE_GEN_PROMPT_BRANCH_SUFFIX": { + "defaultValue": { + "value": "" + }, + "valueType": "STRING", + "description": "Prompt for generating text-based multiple choice values." + }, + "POWERUP_PROMPT_BRANCH_SUFFIX": { + "defaultValue": { + "value": "Give me a prompt for a photo generation model that is extremely detailed and at least two paragraphs long. Refrain from showing any humans in the output images. Make it a flat vector illustration with a whimsical, minimalist aesthetic.\nKey characteristics include:\nGeometric Simplification: The landscape elements—such as the pine trees and bushes—are reduced to clean, angular, and stylized geometric shapes rather than realistic textures.\nPalette and Gradation: It utilizes a harmonious, soft-toned color palette. While the shapes are flat, the atmosphere is created through subtle linear gradients in the sky and soft, layered color blocking to suggest depth and light.\nContoured Line Work: The composition is defined by clean, consistent-weight outlines that give the characters and objects a structured, pop-up book quality.\nStylized Proportion: The subject exhibits \"chibi-adjacent\" or simplified character design, favoring clear, readable features and an expressive, cheerful personality over anatomical realism.\nGraphic Balance: There is a strong emphasis on intentional negative space and clean, rhythmic curves (such as the S-curve of the path), creating a balanced, orderly, and serene visual flow.\nPhoto image:\n" + }, + "valueType": "STRING", + "description": "Prompt for image generation model." + }, + "IMAGE_MODEL_BRANCH_SUFFIX": { + "defaultValue": { + "value": "gemini-3.1-flash-image-preview" + }, + "valueType": "STRING", + "description": "Model used for prompt enhancement (e.g. text model)." + }, + "TEXT_MODEL_BRANCH_SUFFIX": { + "defaultValue": { + "value": "gemini-3-flash-preview" + }, + "valueType": "STRING", + "description": "Text model used for prompt powerup generation." + } + } +} \ No newline at end of file diff --git a/genlatte/firebase/storage.rules b/genlatte/firebase/storage.rules new file mode 100644 index 0000000..9f33d22 --- /dev/null +++ b/genlatte/firebase/storage.rules @@ -0,0 +1,8 @@ +rules_version = '2'; +service firebase.storage { + match /b/{bucket}/o { + match /{allPaths=**} { + allow read, write: if false; + } + } +} diff --git a/genlatte/firebase/tests/CONTEXT.md b/genlatte/firebase/tests/CONTEXT.md new file mode 100644 index 0000000..65bfea0 --- /dev/null +++ b/genlatte/firebase/tests/CONTEXT.md @@ -0,0 +1,12 @@ +# Firebase Security Rule Tests + +**Purpose:** +Provides a suite of automated unit tests to verify the integrity and effectiveness of the project's Firestore and Realtime Database security rules. + +**Detailed File Overviews:** +- `defaultDeny.test.js`: Verifies that the "deny by default" security posture is correctly enforced for unauthenticated requests. +- `latteOrders.test.js` / `latteImageBatches.test.js` / etc.: Specific test suites for each collection, ensuring that role-based access control (RBAC) and data validation rules work as expected for CRUD operations. + +**Dependencies/Relationships:** +- Uses the `@firebase/rules-unit-testing` framework. +- Validates the rules defined in the `firebase/firestore.rules` and `firebase/database.rules.json` files. diff --git a/genlatte/firebase/tests/defaultDeny.test.js b/genlatte/firebase/tests/defaultDeny.test.js new file mode 100644 index 0000000..2088353 --- /dev/null +++ b/genlatte/firebase/tests/defaultDeny.test.js @@ -0,0 +1,85 @@ +const { assertFails, assertSucceeds, initializeTestEnvironment } = require('@firebase/rules-unit-testing'); +const { doc, setDoc, updateDoc, getDoc, deleteDoc } = require('firebase/firestore'); +const { readFileSync } = require('fs'); +const path = require('path'); + +let testEnv; + +before(async () => { + testEnv = await initializeTestEnvironment({ + projectId: 'demo-defaultdeny-rules', + firestore: { + rules: readFileSync(path.resolve(__dirname, '../firestore.rules'), 'utf8'), + host: '127.0.0.1', + port: 8080 + }, + }); +}); + +beforeEach(async () => { + await testEnv.clearFirestore(); + + // setup prerequisite data using bypass + await testEnv.withSecurityRulesDisabled(async (context) => { + const db = context.firestore(); + await setDoc(doc(db, 'unknownCollection/existing_doc'), { data: 'secret' }); + }); +}); + +after(async () => { + if (testEnv) { + await testEnv.cleanup(); + } +}); + +describe('Default Deny Rules', () => { + + let baristaDb; + let unauthDb; + let kioskDb; + + beforeEach(() => { + baristaDb = testEnv.authenticatedContext('barista_user', { barista: true }).firestore(); + unauthDb = testEnv.unauthenticatedContext().firestore(); + kioskDb = testEnv.authenticatedContext('kiosk_user', { kiosk: true }).firestore(); + }); + + describe('Read', () => { + it('should deny barista from reading unknown collections', async () => { + const ref = doc(baristaDb, 'unknownCollection/existing_doc'); + await assertFails(getDoc(ref)); + }); + + it('should deny kiosk from reading unknown collections', async () => { + const ref = doc(kioskDb, 'unknownCollection/existing_doc'); + await assertFails(getDoc(ref)); + }); + + it('should deny unauthed from reading unknown collections', async () => { + const ref = doc(unauthDb, 'unknownCollection/existing_doc'); + await assertFails(getDoc(ref)); + }); + }); + + describe('Write', () => { + it('should deny barista from writing to unknown collections', async () => { + const ref = doc(baristaDb, 'unknownCollection/new_doc'); + await assertFails(setDoc(ref, { data: 'new' })); + }); + + it('should deny kiosk from writing to unknown collections', async () => { + const ref = doc(kioskDb, 'unknownCollection/new_doc'); + await assertFails(setDoc(ref, { data: 'new' })); + }); + + it('should deny unauthed from writing to unknown collections', async () => { + const ref = doc(unauthDb, 'unknownCollection/new_doc'); + await assertFails(setDoc(ref, { data: 'new' })); + }); + + it('should deny barista from deleting unknown collections', async () => { + const ref = doc(baristaDb, 'unknownCollection/existing_doc'); + await assertFails(deleteDoc(ref)); + }); + }); +}); diff --git a/genlatte/firebase/tests/latteImageBatches.test.js b/genlatte/firebase/tests/latteImageBatches.test.js new file mode 100644 index 0000000..1bcfe30 --- /dev/null +++ b/genlatte/firebase/tests/latteImageBatches.test.js @@ -0,0 +1,100 @@ +const { assertFails, assertSucceeds, initializeTestEnvironment } = require('@firebase/rules-unit-testing'); +const { doc, setDoc, updateDoc, getDoc, deleteDoc } = require('firebase/firestore'); +const { readFileSync } = require('fs'); +const path = require('path'); + +let testEnv; + +before(async () => { + testEnv = await initializeTestEnvironment({ + projectId: 'demo-latteimagebatches-rules', + firestore: { + rules: readFileSync(path.resolve(__dirname, '../firestore.rules'), 'utf8'), + host: '127.0.0.1', + port: 8080 + }, + }); +}); + +beforeEach(async () => { + await testEnv.clearFirestore(); + + // setup prerequisite data using bypass + await testEnv.withSecurityRulesDisabled(async (context) => { + const db = context.firestore(); + await setDoc(doc(db, 'latteImageBatches/existing_batch'), { status: 'pending' }); + }); +}); + +after(async () => { + if (testEnv) { + await testEnv.cleanup(); + } +}); + +describe('latteImageBatches Rules', () => { + + let kioskDb; + let baristaDb; + let unauthDb; + + beforeEach(() => { + kioskDb = testEnv.authenticatedContext('kiosk_user', { kiosk: true }).firestore(); + baristaDb = testEnv.authenticatedContext('barista_user', { barista: true }).firestore(); + unauthDb = testEnv.unauthenticatedContext().firestore(); + }); + + describe('Read', () => { + it('should allow kiosk to read batches', async () => { + const ref = doc(kioskDb, 'latteImageBatches/existing_batch'); + await assertSucceeds(getDoc(ref)); + }); + + it('should allow barista to read batches', async () => { + const ref = doc(baristaDb, 'latteImageBatches/existing_batch'); + await assertSucceeds(getDoc(ref)); + }); + + it('should deny unauthed from reading batches', async () => { + const ref = doc(unauthDb, 'latteImageBatches/existing_batch'); + await assertFails(getDoc(ref)); + }); + }); + + describe('Write', () => { + it('should allow barista to create batches', async () => { + const ref = doc(baristaDb, 'latteImageBatches/new_batch'); + await assertSucceeds(setDoc(ref, { status: 'new' })); + }); + + it('should deny kiosk from creating batches', async () => { + const ref = doc(kioskDb, 'latteImageBatches/new_batch'); + await assertFails(setDoc(ref, { status: 'new' })); + }); + + it('should deny unauth from creating batches', async () => { + const ref = doc(unauthDb, 'latteImageBatches/new_batch'); + await assertFails(setDoc(ref, { status: 'new' })); + }); + + it('should allow barista to update batches', async () => { + const ref = doc(baristaDb, 'latteImageBatches/existing_batch'); + await assertSucceeds(updateDoc(ref, { status: 'completed' })); + }); + + it('should deny kiosk from updating batches', async () => { + const ref = doc(kioskDb, 'latteImageBatches/existing_batch'); + await assertFails(updateDoc(ref, { status: 'completed' })); + }); + + it('should allow barista to delete batches', async () => { + const ref = doc(baristaDb, 'latteImageBatches/existing_batch'); + await assertSucceeds(deleteDoc(ref)); + }); + + it('should deny kiosk from deleting batches', async () => { + const ref = doc(kioskDb, 'latteImageBatches/existing_batch'); + await assertFails(deleteDoc(ref)); + }); + }); +}); diff --git a/genlatte/firebase/tests/latteOptions.test.js b/genlatte/firebase/tests/latteOptions.test.js new file mode 100644 index 0000000..80289f8 --- /dev/null +++ b/genlatte/firebase/tests/latteOptions.test.js @@ -0,0 +1,83 @@ +const { assertFails, assertSucceeds, initializeTestEnvironment } = require('@firebase/rules-unit-testing'); +const { doc, setDoc, updateDoc, getDoc, deleteDoc } = require('firebase/firestore'); +const { readFileSync } = require('fs'); +const path = require('path'); + +let testEnv; + +before(async () => { + testEnv = await initializeTestEnvironment({ + projectId: 'demo-latteoptions-rules', + firestore: { + rules: readFileSync(path.resolve(__dirname, '../firestore.rules'), 'utf8'), + host: '127.0.0.1', + port: 8080 + }, + }); +}); + +beforeEach(async () => { + await testEnv.clearFirestore(); + + // setup prerequisite data using bypass + await testEnv.withSecurityRulesDisabled(async (context) => { + const db = context.firestore(); + await setDoc(doc(db, 'latteOptions/existing_option'), { values: ['Test'] }); + }); +}); + +after(async () => { + if (testEnv) { + await testEnv.cleanup(); + } +}); + +describe('latteOptions Rules', () => { + + let unauthDb; + let authDb; + + beforeEach(() => { + unauthDb = testEnv.unauthenticatedContext().firestore(); + authDb = testEnv.authenticatedContext('system_user', {}).firestore(); + }); + + describe('Read', () => { + it('should allow unauth users to read options', async () => { + const ref = doc(unauthDb, 'latteOptions/existing_option'); + await assertSucceeds(getDoc(ref)); + }); + + it('should allow auth users to read options', async () => { + const ref = doc(authDb, 'latteOptions/existing_option'); + await assertSucceeds(getDoc(ref)); + }); + }); + + describe('Write', () => { + it('should deny unauth users from creating options', async () => { + const ref = doc(unauthDb, 'latteOptions/new_option'); + await assertFails(setDoc(ref, { values: ['Test2'] })); + }); + + it('should deny auth users from creating options', async () => { + const ref = doc(authDb, 'latteOptions/new_option'); + await assertFails(setDoc(ref, { values: ['Test2'] })); + }); + + it('should deny unauth users from updating options', async () => { + const ref = doc(unauthDb, 'latteOptions/existing_option'); + await assertFails(updateDoc(ref, { values: ['Updated'] })); + }); + + it('should deny unauth users from deleting options', async () => { + const ref = doc(unauthDb, 'latteOptions/existing_option'); + await assertFails(deleteDoc(ref)); + }); + + it('should deny auth users from deleting options', async () => { + const ref = doc(authDb, 'latteOptions/existing_option'); + await assertFails(deleteDoc(ref)); + }); + }); +}); diff --git a/genlatte/firebase/tests/latteOrderMetadata.test.js b/genlatte/firebase/tests/latteOrderMetadata.test.js new file mode 100644 index 0000000..95dd73c --- /dev/null +++ b/genlatte/firebase/tests/latteOrderMetadata.test.js @@ -0,0 +1,100 @@ +const { assertFails, assertSucceeds, initializeTestEnvironment } = require('@firebase/rules-unit-testing'); +const { doc, setDoc, updateDoc, getDoc, deleteDoc } = require('firebase/firestore'); +const { readFileSync } = require('fs'); +const path = require('path'); + +let testEnv; + +before(async () => { + testEnv = await initializeTestEnvironment({ + projectId: 'demo-latteordermetadata-rules', + firestore: { + rules: readFileSync(path.resolve(__dirname, '../firestore.rules'), 'utf8'), + host: '127.0.0.1', + port: 8080 + }, + }); +}); + +beforeEach(async () => { + await testEnv.clearFirestore(); + + // setup prerequisite data using bypass + await testEnv.withSecurityRulesDisabled(async (context) => { + const db = context.firestore(); + await setDoc(doc(db, 'latteOrderMetadata/existing_metadata'), { notes: 'urgent' }); + }); +}); + +after(async () => { + if (testEnv) { + await testEnv.cleanup(); + } +}); + +describe('latteOrderMetadata Rules', () => { + + let kioskDb; + let baristaDb; + let unauthDb; + + beforeEach(() => { + kioskDb = testEnv.authenticatedContext('kiosk_user', { kiosk: true }).firestore(); + baristaDb = testEnv.authenticatedContext('barista_user', { barista: true }).firestore(); + unauthDb = testEnv.unauthenticatedContext().firestore(); + }); + + describe('Read', () => { + it('should allow kiosk to read metadata', async () => { + const ref = doc(kioskDb, 'latteOrderMetadata/existing_metadata'); + await assertSucceeds(getDoc(ref)); + }); + + it('should allow barista to read metadata', async () => { + const ref = doc(baristaDb, 'latteOrderMetadata/existing_metadata'); + await assertSucceeds(getDoc(ref)); + }); + + it('should deny unauthed from reading metadata', async () => { + const ref = doc(unauthDb, 'latteOrderMetadata/existing_metadata'); + await assertFails(getDoc(ref)); + }); + }); + + describe('Write', () => { + it('should allow barista to create metadata', async () => { + const ref = doc(baristaDb, 'latteOrderMetadata/new_metadata'); + await assertSucceeds(setDoc(ref, { notes: 'new' })); + }); + + it('should deny kiosk from creating metadata', async () => { + const ref = doc(kioskDb, 'latteOrderMetadata/new_metadata'); + await assertFails(setDoc(ref, { notes: 'new' })); + }); + + it('should deny unauth from creating metadata', async () => { + const ref = doc(unauthDb, 'latteOrderMetadata/new_metadata'); + await assertFails(setDoc(ref, { notes: 'new' })); + }); + + it('should allow barista to update metadata', async () => { + const ref = doc(baristaDb, 'latteOrderMetadata/existing_metadata'); + await assertSucceeds(updateDoc(ref, { notes: 'updated' })); + }); + + it('should deny kiosk from updating metadata', async () => { + const ref = doc(kioskDb, 'latteOrderMetadata/existing_metadata'); + await assertFails(updateDoc(ref, { notes: 'updated' })); + }); + + it('should allow barista to delete metadata', async () => { + const ref = doc(baristaDb, 'latteOrderMetadata/existing_metadata'); + await assertSucceeds(deleteDoc(ref)); + }); + + it('should deny kiosk from deleting metadata', async () => { + const ref = doc(kioskDb, 'latteOrderMetadata/existing_metadata'); + await assertFails(deleteDoc(ref)); + }); + }); +}); diff --git a/genlatte/firebase/tests/latteOrders.test.js b/genlatte/firebase/tests/latteOrders.test.js new file mode 100644 index 0000000..f16112b --- /dev/null +++ b/genlatte/firebase/tests/latteOrders.test.js @@ -0,0 +1,138 @@ +const { assertFails, assertSucceeds, initializeTestEnvironment } = require('@firebase/rules-unit-testing'); +const { doc, setDoc, updateDoc, getDoc, deleteDoc } = require('firebase/firestore'); +const { readFileSync } = require('fs'); +const path = require('path'); + +let testEnv; + +before(async () => { + testEnv = await initializeTestEnvironment({ + projectId: 'demo-latteart-rules', + firestore: { + rules: readFileSync(path.resolve(__dirname, '../firestore.rules'), 'utf8'), + host: '127.0.0.1', + port: 8080 + }, + }); +}); + +beforeEach(async () => { + await testEnv.clearFirestore(); + + // setup prerequisite data using bypass + await testEnv.withSecurityRulesDisabled(async (context) => { + const db = context.firestore(); + await setDoc(doc(db, 'latteOptions/milks'), { values: ['Whole', 'Skim', 'Oat', 'Almond'] }); + await setDoc(doc(db, 'latteOptions/sweeteners'), { values: ['None', 'Sugar', 'Stevia', 'Caramel'] }); + + // pre-seed an order for testing updates + await setDoc(doc(db, 'latteOrders/existing_order'), { + name: 'Craig' + }); + }); +}); + +after(async () => { + if (testEnv) { + await testEnv.cleanup(); + } +}); + +describe('latteOrders Rules', () => { + + let kioskDb; + let baristaDb; + let unauthDb; + + beforeEach(() => { + kioskDb = testEnv.authenticatedContext('kiosk_user', { kiosk: true }).firestore(); + baristaDb = testEnv.authenticatedContext('barista_user', { barista: true }).firestore(); + unauthDb = testEnv.unauthenticatedContext().firestore(); + }); + + describe('Create', () => { + it('should allow kiosk to create with just a name', async () => { + const ref = doc(kioskDb, 'latteOrders/order1'); + await assertSucceeds(setDoc(ref, { name: 'Latte' })); + }); + + it('should fail if kiosk tries to create with an extra arbitrary field', async () => { + const ref = doc(kioskDb, 'latteOrders/order2'); + await assertFails(setDoc(ref, { name: 'Latte', badField: true })); + }); + + it('should deny barista from creating orders', async () => { + const ref = doc(baristaDb, 'latteOrders/order3'); + await assertFails(setDoc(ref, { name: 'Latte' })); + }); + + it('should deny unauthed from creating orders', async () => { + const ref = doc(unauthDb, 'latteOrders/order4'); + await assertFails(setDoc(ref, { name: 'Latte' })); + }); + }); + + describe('Update', () => { + it('should allow barista to update orders without restriction on milk validity', async () => { + const ref = doc(baristaDb, 'latteOrders/existing_order'); + await assertSucceeds(updateDoc(ref, { milk: 'unlisted-super-milk' })); + }); + + it('should allow kiosk to update order with valid milk', async () => { + const ref = doc(kioskDb, 'latteOrders/existing_order'); + await assertSucceeds(updateDoc(ref, { milk: 'Oat' })); + }); + + it('should fail if kiosk tries to update order with invalid milk', async () => { + const ref = doc(kioskDb, 'latteOrders/existing_order'); + await assertFails(updateDoc(ref, { milk: 'invalid_milk' })); + }); + + it('should allow kiosk to update order with valid sweetener', async () => { + const ref = doc(kioskDb, 'latteOrders/existing_order'); + await assertSucceeds(updateDoc(ref, { sweetener: 'Caramel' })); + }); + + it('should fail if kiosk tries to update order with invalid sweetener', async () => { + const ref = doc(kioskDb, 'latteOrders/existing_order'); + await assertFails(updateDoc(ref, { sweetener: 'salt' })); + }); + + it('should allow kiosk to update image URL', async () => { + const ref = doc(kioskDb, 'latteOrders/existing_order'); + await assertSucceeds(updateDoc(ref, { imageUrl: 'https://example.com/image.png' })); + }); + + it('should allow kiosks to update milk and sweetener at the same time', async () => { + const ref = doc(kioskDb, 'latteOrders/existing_order'); + await assertSucceeds(updateDoc(ref, { milk: 'Oat', sweetener: 'Caramel' })); + }); + }); + + describe('Read & Delete', () => { + it('should allow barista to read orders', async () => { + const ref = doc(baristaDb, 'latteOrders/existing_order'); + await assertSucceeds(getDoc(ref)); + }); + + it('should allow kiosk to read orders', async () => { + const ref = doc(kioskDb, 'latteOrders/existing_order'); + await assertSucceeds(getDoc(ref)); + }); + + it('should deny unauthed from reading orders', async () => { + const ref = doc(unauthDb, 'latteOrders/existing_order'); + await assertFails(getDoc(ref)); + }); + + it('should deny kiosk from deleting orders', async () => { + const ref = doc(kioskDb, 'latteOrders/existing_order'); + await assertFails(deleteDoc(ref)); + }); + + it('should allow barista to delete orders', async () => { + const ref = doc(baristaDb, 'latteOrders/existing_order'); + await assertSucceeds(deleteDoc(ref)); + }); + }); +}); diff --git a/genlatte/genlatte-data/.gitignore b/genlatte/genlatte-data/.gitignore new file mode 100644 index 0000000..000afeb --- /dev/null +++ b/genlatte/genlatte-data/.gitignore @@ -0,0 +1,8 @@ +# https://dart.dev/guides/libraries/private-files +# Created by `dart pub` +.dart_tool/ +build/** + +# Avoid committing pubspec.lock for library packages; see +# https://dart.dev/guides/libraries/private-files#pubspeclock. +pubspec.lock diff --git a/genlatte/genlatte-data/CHANGELOG.md b/genlatte/genlatte-data/CHANGELOG.md new file mode 100644 index 0000000..effe43c --- /dev/null +++ b/genlatte/genlatte-data/CHANGELOG.md @@ -0,0 +1,3 @@ +## 1.0.0 + +- Initial version. diff --git a/genlatte/genlatte-data/CONTEXT.md b/genlatte/genlatte-data/CONTEXT.md new file mode 100644 index 0000000..81caf45 --- /dev/null +++ b/genlatte/genlatte-data/CONTEXT.md @@ -0,0 +1,22 @@ +# Shared Models Package (genlatte-data) + +## Purpose +A pure-Dart package that defines the core data structures and domain models shared across the entire GenLatte ecosystem. This ensures type safety and consistent data serialization between the Flutter UI, Cloud Functions, and Cloud Run jobs. + +## Detailed File Overviews +- `pubspec.yaml`: Defines dependencies, notably the `freezed` and `json_serializable` packages for code generation. +- `analysis_options.yaml`: Standardized linting rules for the Dart package. +- `CHANGELOG.md`: Tracks version changes of the data models. + +## Subdirectories +- `lib/`: Contains the exported model definitions. + - `src/models/`: The actual implementation of domain models (e.g., `LatteOrder`, `Barista`, `Machine`) using `freezed` for immutability and JSON support. +- `test/`: Unit tests for model serialization and business logic contained within the models. + +## Implementation Details +- **Architecture**: A logic-only Dart package without any dependency on the Flutter framework, making it suitable for both frontend and backend use. +- **Code Generation**: Uses `build_runner` to synthesize the `*.freezed.dart` and `*.g.dart` files. Run `dart run build_runner build` to regenerate these files after model changes. + +## Dependencies/Relationships +- **Frontend:** Imported by `genlatte-ui` to handle UI state and API responses. +- **Backend:** Used by Cloud Functions and Cloud Run services to ensure data integrity when interacting with Firestore and external APIs. diff --git a/genlatte/genlatte-data/README.md b/genlatte/genlatte-data/README.md new file mode 100644 index 0000000..8831761 --- /dev/null +++ b/genlatte/genlatte-data/README.md @@ -0,0 +1,39 @@ + + +TODO: Put a short description of the package here that helps potential users +know whether this package might be useful for them. + +## Features + +TODO: List what your package can do. Maybe include images, gifs, or videos. + +## Getting started + +TODO: List prerequisites and provide or point to information on how to +start using the package. + +## Usage + +TODO: Include short and useful examples for package users. Add longer examples +to `/example` folder. + +```dart +const like = 'sample'; +``` + +## Additional information + +TODO: Tell users more about the package: where to find more information, how to +contribute to the package, how to file issues, what response they can expect +from the package authors, and more. diff --git a/genlatte/genlatte-data/analysis_options.yaml b/genlatte/genlatte-data/analysis_options.yaml new file mode 100644 index 0000000..fcf0b22 --- /dev/null +++ b/genlatte/genlatte-data/analysis_options.yaml @@ -0,0 +1,17 @@ +include: package:very_good_analysis/analysis_options.yaml +linter: + rules: + public_member_api_docs: true + omit_local_variable_types: false + document_ignores: true + +analyzer: + errors: + todo: ignore + document_ignores: ignore + exclude: + - "**.freezed.dart" + - "**.g.dart" + +formatter: + trailing_commas: preserve \ No newline at end of file diff --git a/genlatte/genlatte-data/lib/CONTEXT.md b/genlatte/genlatte-data/lib/CONTEXT.md new file mode 100644 index 0000000..7cdcac8 --- /dev/null +++ b/genlatte/genlatte-data/lib/CONTEXT.md @@ -0,0 +1,4 @@ +# genlatte-data Lib Root + +**Purpose:** +Base logical entrypoint encapsulating `models/` mapping schemas. diff --git a/genlatte/genlatte-data/lib/models.dart b/genlatte/genlatte-data/lib/models.dart new file mode 100644 index 0000000..a78c9ab --- /dev/null +++ b/genlatte/genlatte-data/lib/models.dart @@ -0,0 +1,5 @@ +// Copyright 2014 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 'src/models/models.dart'; diff --git a/genlatte/genlatte-data/lib/src/CONTEXT.md b/genlatte/genlatte-data/lib/src/CONTEXT.md new file mode 100644 index 0000000..31d2f86 --- /dev/null +++ b/genlatte/genlatte-data/lib/src/CONTEXT.md @@ -0,0 +1,7 @@ +# Shared Data Package Sub-Tree + +**Purpose:** +A structural grouping boundary for `genlatte_data`. + +**Implementation Details:** +- **Navigation**: All critical logic resides deeply within `models/`, representing universal schema boundaries across the different systems. diff --git a/genlatte/genlatte-data/lib/src/models/CONTEXT.md b/genlatte/genlatte-data/lib/src/models/CONTEXT.md new file mode 100644 index 0000000..5f2d7e3 --- /dev/null +++ b/genlatte/genlatte-data/lib/src/models/CONTEXT.md @@ -0,0 +1,28 @@ +# Shared Models + +**Purpose:** +Provides pure Dart representations of the NoSQL Firebase document structures. Ensures that all applications (frontend, backend scripts, emulators) share exactly the same data boundaries and serialization logic using `freezed` and `json_serializable`. + +**Detailed File Overviews:** + +- `models.dart`: + - **Description**: Barrel export file. + +- `latte_order.dart`: + - **Description**: The core foundational entity representing a Customer's Order. + - **Core Logic**: Contains nested architectures (`Latte`, `LatteOrder`, and `LatteOrderMetadata`). Generates JSON mapping logic tracking properties like `id`, `name`, `status`, `imageUrl`, and timestamps `orderSubmittedTime` / `completionTime`. + +- `latte_image.dart` / `latte_options.dart`: + - **Description**: Defines visual configuration options for image generation (temperature, seeds, and image URLs). + +- `machine.dart`: + - **Description**: Represents the physical printing hardware located dynamically around the event floor. + +- `question.dart`: + - **Description**: Models structures for the visual Questionnaire ("Is it sunny?", "How do you feel?") utilized inside the Kiosk wizard flow. + +- `barista.dart`: + - **Description**: Encapsulates metadata specifically relating to the employee actively fulfilling orders. + +**Dependencies/Relationships:** +- Serves as the ultimate source of truth for schema validation across the `genlatte_data` package. Generated via `build_runner`. diff --git a/genlatte/genlatte-data/lib/src/models/barista.dart b/genlatte/genlatte-data/lib/src/models/barista.dart new file mode 100644 index 0000000..b7ed861 --- /dev/null +++ b/genlatte/genlatte-data/lib/src/models/barista.dart @@ -0,0 +1,83 @@ +// Copyright 2014 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:data_layer/data_layer.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'barista.freezed.dart'; +part 'barista.g.dart'; + +/// An admin worker can moderate and fulfill orders. +@freezed +abstract class Barista with _$Barista { + /// Instantiates a [Barista]. + const factory Barista({ + required String username, + required BaristaPersona persona, + String? id, + }) = _Barista; + + /// Json deserializer for [Barista]. + factory Barista.fromJson(Map json) => + _$BaristaFromJson(json); + + /// Data layer bindings for [Barista]. + static Bindings bindings = Bindings( + fromJson: Barista.fromJson, + toJson: (obj) => obj.toJson(), + getId: (item) => item.id, + getDetailUrl: (id) => ApiUrl(path: 'baristas/$id'), + getListUrl: () => const ApiUrl(path: 'baristas'), + ); +} + +/// Generic avatars for [Barista]s. +enum BaristaPersona { + /// A Black female barista. + blackFemale, + + /// An Asian female barista. + asianFemale, + + /// A Caucasian female barista. + caucasianFemale, + + /// A Hispanic female barista. + hispanicFemale, + + /// An Indian female barista. + indianFemale, + + /// A Black male barista. + blackMale, + + /// An Asian male barista. + asianMale, + + /// A Caucasian male barista. + caucasianMale, + + /// A Hispanic male barista. + hispanicMale, + + /// An Indian male barista. + indianMale; + + /// The asset name for the avatar image. + String get assetName => 'assets/avatars/$name.png'; +} + +/// Extension for [Barista] lists. +extension BaristaList on List { + /// Converts the list of orders into a map keyed by order id. + Map toIdsMap() { + return fold>( + {}, + (map, order) { + map[order.id!] = order; + return map; + }, + ); + } +} diff --git a/genlatte/genlatte-data/lib/src/models/barista.freezed.dart b/genlatte/genlatte-data/lib/src/models/barista.freezed.dart new file mode 100644 index 0000000..8bcbdb2 --- /dev/null +++ b/genlatte/genlatte-data/lib/src/models/barista.freezed.dart @@ -0,0 +1,287 @@ +// Copyright 2014 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. + +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'barista.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; + +/// @nodoc +mixin _$Barista { + + String get username; BaristaPersona get persona; String? get id; +/// Create a copy of Barista +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$BaristaCopyWith get copyWith => _$BaristaCopyWithImpl(this as Barista, _$identity); + + /// Serializes this Barista to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is Barista&&(identical(other.username, username) || other.username == username)&&(identical(other.persona, persona) || other.persona == persona)&&(identical(other.id, id) || other.id == id)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,username,persona,id); + +@override +String toString() { + return 'Barista(username: $username, persona: $persona, id: $id)'; +} + + +} + +/// @nodoc +abstract mixin class $BaristaCopyWith<$Res> { + factory $BaristaCopyWith(Barista value, $Res Function(Barista) _then) = _$BaristaCopyWithImpl; +@useResult +$Res call({ + String username, BaristaPersona persona, String? id +}); + + + + +} +/// @nodoc +class _$BaristaCopyWithImpl<$Res> + implements $BaristaCopyWith<$Res> { + _$BaristaCopyWithImpl(this._self, this._then); + + final Barista _self; + final $Res Function(Barista) _then; + +/// Create a copy of Barista +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? username = null,Object? persona = null,Object? id = freezed,}) { + return _then(_self.copyWith( +username: null == username ? _self.username : username // ignore: cast_nullable_to_non_nullable +as String,persona: null == persona ? _self.persona : persona // ignore: cast_nullable_to_non_nullable +as BaristaPersona,id: freezed == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String?, + )); +} + +} + + +/// Adds pattern-matching-related methods to [Barista]. +extension BaristaPatterns on Barista { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _Barista value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _Barista() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _Barista value) $default,){ +final _that = this; +switch (_that) { +case _Barista(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _Barista value)? $default,){ +final _that = this; +switch (_that) { +case _Barista() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String username, BaristaPersona persona, String? id)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _Barista() when $default != null: +return $default(_that.username,_that.persona,_that.id);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String username, BaristaPersona persona, String? id) $default,) {final _that = this; +switch (_that) { +case _Barista(): +return $default(_that.username,_that.persona,_that.id);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String username, BaristaPersona persona, String? id)? $default,) {final _that = this; +switch (_that) { +case _Barista() when $default != null: +return $default(_that.username,_that.persona,_that.id);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _Barista implements Barista { + const _Barista({required this.username, required this.persona, this.id}); + factory _Barista.fromJson(Map json) => _$BaristaFromJson(json); + +@override final String username; +@override final BaristaPersona persona; +@override final String? id; + +/// Create a copy of Barista +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$BaristaCopyWith<_Barista> get copyWith => __$BaristaCopyWithImpl<_Barista>(this, _$identity); + +@override +Map toJson() { + return _$BaristaToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _Barista&&(identical(other.username, username) || other.username == username)&&(identical(other.persona, persona) || other.persona == persona)&&(identical(other.id, id) || other.id == id)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,username,persona,id); + +@override +String toString() { + return 'Barista(username: $username, persona: $persona, id: $id)'; +} + + +} + +/// @nodoc +abstract mixin class _$BaristaCopyWith<$Res> implements $BaristaCopyWith<$Res> { + factory _$BaristaCopyWith(_Barista value, $Res Function(_Barista) _then) = __$BaristaCopyWithImpl; +@override @useResult +$Res call({ + String username, BaristaPersona persona, String? id +}); + + + + +} +/// @nodoc +class __$BaristaCopyWithImpl<$Res> + implements _$BaristaCopyWith<$Res> { + __$BaristaCopyWithImpl(this._self, this._then); + + final _Barista _self; + final $Res Function(_Barista) _then; + +/// Create a copy of Barista +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? username = null,Object? persona = null,Object? id = freezed,}) { + return _then(_Barista( +username: null == username ? _self.username : username // ignore: cast_nullable_to_non_nullable +as String,persona: null == persona ? _self.persona : persona // ignore: cast_nullable_to_non_nullable +as BaristaPersona,id: freezed == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String?, + )); +} + + +} + +// dart format on diff --git a/genlatte/genlatte-data/lib/src/models/barista.g.dart b/genlatte/genlatte-data/lib/src/models/barista.g.dart new file mode 100644 index 0000000..04fcf40 --- /dev/null +++ b/genlatte/genlatte-data/lib/src/models/barista.g.dart @@ -0,0 +1,36 @@ +// Copyright 2014 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. + +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'barista.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_Barista _$BaristaFromJson(Map json) => _Barista( + username: json['username'] as String, + persona: $enumDecode(_$BaristaPersonaEnumMap, json['persona']), + id: json['id'] as String?, +); + +Map _$BaristaToJson(_Barista instance) => { + 'username': instance.username, + 'persona': _$BaristaPersonaEnumMap[instance.persona]!, + 'id': instance.id, +}; + +const _$BaristaPersonaEnumMap = { + BaristaPersona.blackFemale: 'blackFemale', + BaristaPersona.asianFemale: 'asianFemale', + BaristaPersona.caucasianFemale: 'caucasianFemale', + BaristaPersona.hispanicFemale: 'hispanicFemale', + BaristaPersona.indianFemale: 'indianFemale', + BaristaPersona.blackMale: 'blackMale', + BaristaPersona.asianMale: 'asianMale', + BaristaPersona.caucasianMale: 'caucasianMale', + BaristaPersona.hispanicMale: 'hispanicMale', + BaristaPersona.indianMale: 'indianMale', +}; diff --git a/genlatte/genlatte-data/lib/src/models/latte_image.dart b/genlatte/genlatte-data/lib/src/models/latte_image.dart new file mode 100644 index 0000000..421583e --- /dev/null +++ b/genlatte/genlatte-data/lib/src/models/latte_image.dart @@ -0,0 +1,218 @@ +// Copyright 2014 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. + +/// @docImport 'package:genlatte_data/src/models/latte_order.dart'; +library; + +import 'package:data_layer/api.dart'; +import 'package:data_layer/sources.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:genlatte_data/models.dart' show Question, QuestionConverter; + +part 'latte_image.freezed.dart'; +part 'latte_image.g.dart'; + +/// A set of 4 [LatteImage]s created for a [LatteOrder], to be viewed together. +@freezed +abstract class LatteImageBatch with _$LatteImageBatch { + /// Instantiates a [LatteImageBatch]. + const factory LatteImageBatch({ + required String id, + required String orderId, + @LatteImageConverter() required LatteImage? image0, + @LatteImageConverter() required LatteImage? image1, + @LatteImageConverter() required LatteImage? image2, + @LatteImageConverter() required LatteImage? image3, + @LatteImageBatchParentConverter() LatteImageBatchParent? parent, + }) = _LatteImageBatch; + + const LatteImageBatch._(); + + /// Json deserializer for [LatteImageBatch]. + factory LatteImageBatch.fromJson(Map json) => + _$LatteImageBatchFromJson(json); + + /// Iterates over all images. + Iterable get images sync* { + yield image0; + yield image1; + yield image2; + yield image3; + } + + /// Returns the image at the given index. + LatteImage? operator [](int index) => switch (index) { + 0 => image0, + 1 => image1, + 2 => image2, + 3 => image3, + _ => throw RangeError('Invalid image index: $index'), + }; + + /// Data Layer bindings for [LatteImageBatch]. + static Bindings bindings = Bindings( + fromJson: LatteImageBatch.fromJson, + toJson: (obj) => obj.toJson(), + getId: (obj) => obj.id, + getDetailUrl: (id) => ApiUrl(path: 'latteImageBatches/$id'), + getListUrl: () => const ApiUrl(path: 'latteImageBatches'), + ); + + /// Returns a copy of the [LatteImageBatch] with the questions for the + /// selected image updated. + LatteImageBatch answerQuestion( + int index, + Question question, + Object? answer, + ) => switch (index) { + 0 => copyWith(image0: image0!.answerQuestion(question, answer)), + 1 => copyWith(image1: image1!.answerQuestion(question, answer)), + 2 => copyWith(image2: image2!.answerQuestion(question, answer)), + 3 => copyWith(image3: image3!.answerQuestion(question, answer)), + _ => throw RangeError('Invalid image index: $index'), + }; +} + +@freezed +/// Pointers toward the [LatteImageBatch] and specific image field that this +/// image batch forked from. +abstract class LatteImageBatchParent with _$LatteImageBatchParent { + /// Instantiates a [LatteImageBatchParent]. + const factory LatteImageBatchParent({ + /// Firebase Id of the [LatteImageBatch] that this image batch forked from. + required String id, + + /// "image0", "image1", "image2", or "image3". No other values are + /// acceptable. + required String imageIndex, + }) = _LatteImageBatchParent; + + /// Json deserializer for [LatteImageBatchParent]. + factory LatteImageBatchParent.fromJson(Map json) => + _$LatteImageBatchParentFromJson(json); +} + +/// Candidate latte art generated by Nanobanana. +@freezed +abstract class LatteImage with _$LatteImage { + /// Creates a new [LatteImage]. + const factory LatteImage({ + /// The URL of the generated image. + required String imageUrl, + + /// The upgraded prompt sent to Gemini. + required String prompt, + + @QuestionConverter() required List? questions, + + /// A description of the generated image. + required String description, + }) = _LatteImage; + + const LatteImage._(); + + /// Json deserializer for [LatteImage]. + factory LatteImage.fromJson(Map json) => + _$LatteImageFromJson(json); + + /// Returns a copy of the [LatteImage] with given question answered. + LatteImage answerQuestion(Question question, Object? answer) { + assert( + questions != null && questions!.isNotEmpty, + 'Cannot answer questions on a LatteImage without any.', + ); + final questionCopies = List.from(questions!); + for (final (index, q) in questionCopies.indexed) { + if (q.id == question.id) { + questionCopies[index] = q.copyWithAnswer(answer); + return copyWith(questions: questionCopies); + } + } + throw Exception('Question ${question.id} not found in LatteImage.'); + } +} + +/// Candidate latte art generated by Nanobanana. +@freezed +abstract class RecentLatteImage with _$RecentLatteImage { + /// Creates a new [LatteImage]. + const factory RecentLatteImage({ + /// The URL of the generated image. + required String imageUrl, + + /// The upgraded prompt sent to Gemini. + required String prompt, + + /// The happy place that was used to generate the image. + required String happyPlace, + + /// A description of the generated image. + required String description, + + /// The name of the user who created this image. + required String name, + + /// The time the originating [LatteOrder] was submitted and this document + /// was created. + required DateTime createdAt, + + /// The ID of the generated image. + String? id, + }) = _RecentLatteImage; + + const RecentLatteImage._(); + + /// Json deserializer for [RecentLatteImage]. + factory RecentLatteImage.fromJson(Map json) => + _$RecentLatteImageFromJson(json); + + /// Data Layer bindings for [RecentLatteImage] as recent images. + static Bindings recentBindings = Bindings( + fromJson: (data) { + // When objects are brand new, they can briefly be emitted before the + // server actually assigns a `createdAt` value. However, that missing + // value will be very close to `now`, so that is a suitable fallback. + final Object? createdAt = data['createdAt']; + if (createdAt == null || (createdAt is String && createdAt.isEmpty)) { + data['createdAt'] = DateTime.now().toUtc().toIso8601String(); + } + return RecentLatteImage.fromJson(data); + }, + toJson: (obj) => obj.toJson(), + getId: (obj) => obj.id, + getDetailUrl: (id) => ApiUrl(path: 'recentLatteImages/$id'), + getListUrl: () => const ApiUrl(path: 'recentLatteImages'), + ); +} + +/// {@template LatteImageConverter} +/// Json converter for [LatteImage]. +/// {@endtemplate} +class LatteImageConverter + extends JsonConverter> { + /// {@macro LatteImageConverter} + const LatteImageConverter(); + + @override + LatteImage fromJson(Map json) => LatteImage.fromJson(json); + + @override + Map toJson(LatteImage object) => object.toJson(); +} + +/// {@template LatteImageBatchParentConverter} +/// Json converter for [LatteImageBatchParent]. +/// {@endtemplate} +class LatteImageBatchParentConverter + extends JsonConverter> { + /// {@macro LatteImageBatchParentConverter} + const LatteImageBatchParentConverter(); + + @override + LatteImageBatchParent fromJson(Map json) => + LatteImageBatchParent.fromJson(json); + + @override + Map toJson(LatteImageBatchParent object) => object.toJson(); +} diff --git a/genlatte/genlatte-data/lib/src/models/latte_image.freezed.dart b/genlatte/genlatte-data/lib/src/models/latte_image.freezed.dart new file mode 100644 index 0000000..eea4864 --- /dev/null +++ b/genlatte/genlatte-data/lib/src/models/latte_image.freezed.dart @@ -0,0 +1,1274 @@ +// Copyright 2014 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. + +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'latte_image.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; + +/// @nodoc +mixin _$LatteImageBatch { + + String get id; String get orderId;@LatteImageConverter() LatteImage? get image0;@LatteImageConverter() LatteImage? get image1;@LatteImageConverter() LatteImage? get image2;@LatteImageConverter() LatteImage? get image3;@LatteImageBatchParentConverter() LatteImageBatchParent? get parent; +/// Create a copy of LatteImageBatch +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$LatteImageBatchCopyWith get copyWith => _$LatteImageBatchCopyWithImpl(this as LatteImageBatch, _$identity); + + /// Serializes this LatteImageBatch to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is LatteImageBatch&&(identical(other.id, id) || other.id == id)&&(identical(other.orderId, orderId) || other.orderId == orderId)&&(identical(other.image0, image0) || other.image0 == image0)&&(identical(other.image1, image1) || other.image1 == image1)&&(identical(other.image2, image2) || other.image2 == image2)&&(identical(other.image3, image3) || other.image3 == image3)&&(identical(other.parent, parent) || other.parent == parent)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,id,orderId,image0,image1,image2,image3,parent); + +@override +String toString() { + return 'LatteImageBatch(id: $id, orderId: $orderId, image0: $image0, image1: $image1, image2: $image2, image3: $image3, parent: $parent)'; +} + + +} + +/// @nodoc +abstract mixin class $LatteImageBatchCopyWith<$Res> { + factory $LatteImageBatchCopyWith(LatteImageBatch value, $Res Function(LatteImageBatch) _then) = _$LatteImageBatchCopyWithImpl; +@useResult +$Res call({ + String id, String orderId,@LatteImageConverter() LatteImage? image0,@LatteImageConverter() LatteImage? image1,@LatteImageConverter() LatteImage? image2,@LatteImageConverter() LatteImage? image3,@LatteImageBatchParentConverter() LatteImageBatchParent? parent +}); + + +$LatteImageCopyWith<$Res>? get image0;$LatteImageCopyWith<$Res>? get image1;$LatteImageCopyWith<$Res>? get image2;$LatteImageCopyWith<$Res>? get image3;$LatteImageBatchParentCopyWith<$Res>? get parent; + +} +/// @nodoc +class _$LatteImageBatchCopyWithImpl<$Res> + implements $LatteImageBatchCopyWith<$Res> { + _$LatteImageBatchCopyWithImpl(this._self, this._then); + + final LatteImageBatch _self; + final $Res Function(LatteImageBatch) _then; + +/// Create a copy of LatteImageBatch +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? orderId = null,Object? image0 = freezed,Object? image1 = freezed,Object? image2 = freezed,Object? image3 = freezed,Object? parent = freezed,}) { + return _then(_self.copyWith( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String,orderId: null == orderId ? _self.orderId : orderId // ignore: cast_nullable_to_non_nullable +as String,image0: freezed == image0 ? _self.image0 : image0 // ignore: cast_nullable_to_non_nullable +as LatteImage?,image1: freezed == image1 ? _self.image1 : image1 // ignore: cast_nullable_to_non_nullable +as LatteImage?,image2: freezed == image2 ? _self.image2 : image2 // ignore: cast_nullable_to_non_nullable +as LatteImage?,image3: freezed == image3 ? _self.image3 : image3 // ignore: cast_nullable_to_non_nullable +as LatteImage?,parent: freezed == parent ? _self.parent : parent // ignore: cast_nullable_to_non_nullable +as LatteImageBatchParent?, + )); +} +/// Create a copy of LatteImageBatch +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$LatteImageCopyWith<$Res>? get image0 { + if (_self.image0 == null) { + return null; + } + + return $LatteImageCopyWith<$Res>(_self.image0!, (value) { + return _then(_self.copyWith(image0: value)); + }); +}/// Create a copy of LatteImageBatch +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$LatteImageCopyWith<$Res>? get image1 { + if (_self.image1 == null) { + return null; + } + + return $LatteImageCopyWith<$Res>(_self.image1!, (value) { + return _then(_self.copyWith(image1: value)); + }); +}/// Create a copy of LatteImageBatch +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$LatteImageCopyWith<$Res>? get image2 { + if (_self.image2 == null) { + return null; + } + + return $LatteImageCopyWith<$Res>(_self.image2!, (value) { + return _then(_self.copyWith(image2: value)); + }); +}/// Create a copy of LatteImageBatch +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$LatteImageCopyWith<$Res>? get image3 { + if (_self.image3 == null) { + return null; + } + + return $LatteImageCopyWith<$Res>(_self.image3!, (value) { + return _then(_self.copyWith(image3: value)); + }); +}/// Create a copy of LatteImageBatch +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$LatteImageBatchParentCopyWith<$Res>? get parent { + if (_self.parent == null) { + return null; + } + + return $LatteImageBatchParentCopyWith<$Res>(_self.parent!, (value) { + return _then(_self.copyWith(parent: value)); + }); +} +} + + +/// Adds pattern-matching-related methods to [LatteImageBatch]. +extension LatteImageBatchPatterns on LatteImageBatch { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _LatteImageBatch value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _LatteImageBatch() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _LatteImageBatch value) $default,){ +final _that = this; +switch (_that) { +case _LatteImageBatch(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _LatteImageBatch value)? $default,){ +final _that = this; +switch (_that) { +case _LatteImageBatch() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String id, String orderId, @LatteImageConverter() LatteImage? image0, @LatteImageConverter() LatteImage? image1, @LatteImageConverter() LatteImage? image2, @LatteImageConverter() LatteImage? image3, @LatteImageBatchParentConverter() LatteImageBatchParent? parent)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _LatteImageBatch() when $default != null: +return $default(_that.id,_that.orderId,_that.image0,_that.image1,_that.image2,_that.image3,_that.parent);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String id, String orderId, @LatteImageConverter() LatteImage? image0, @LatteImageConverter() LatteImage? image1, @LatteImageConverter() LatteImage? image2, @LatteImageConverter() LatteImage? image3, @LatteImageBatchParentConverter() LatteImageBatchParent? parent) $default,) {final _that = this; +switch (_that) { +case _LatteImageBatch(): +return $default(_that.id,_that.orderId,_that.image0,_that.image1,_that.image2,_that.image3,_that.parent);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String id, String orderId, @LatteImageConverter() LatteImage? image0, @LatteImageConverter() LatteImage? image1, @LatteImageConverter() LatteImage? image2, @LatteImageConverter() LatteImage? image3, @LatteImageBatchParentConverter() LatteImageBatchParent? parent)? $default,) {final _that = this; +switch (_that) { +case _LatteImageBatch() when $default != null: +return $default(_that.id,_that.orderId,_that.image0,_that.image1,_that.image2,_that.image3,_that.parent);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _LatteImageBatch extends LatteImageBatch { + const _LatteImageBatch({required this.id, required this.orderId, @LatteImageConverter() required this.image0, @LatteImageConverter() required this.image1, @LatteImageConverter() required this.image2, @LatteImageConverter() required this.image3, @LatteImageBatchParentConverter() this.parent}): super._(); + factory _LatteImageBatch.fromJson(Map json) => _$LatteImageBatchFromJson(json); + +@override final String id; +@override final String orderId; +@override@LatteImageConverter() final LatteImage? image0; +@override@LatteImageConverter() final LatteImage? image1; +@override@LatteImageConverter() final LatteImage? image2; +@override@LatteImageConverter() final LatteImage? image3; +@override@LatteImageBatchParentConverter() final LatteImageBatchParent? parent; + +/// Create a copy of LatteImageBatch +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$LatteImageBatchCopyWith<_LatteImageBatch> get copyWith => __$LatteImageBatchCopyWithImpl<_LatteImageBatch>(this, _$identity); + +@override +Map toJson() { + return _$LatteImageBatchToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _LatteImageBatch&&(identical(other.id, id) || other.id == id)&&(identical(other.orderId, orderId) || other.orderId == orderId)&&(identical(other.image0, image0) || other.image0 == image0)&&(identical(other.image1, image1) || other.image1 == image1)&&(identical(other.image2, image2) || other.image2 == image2)&&(identical(other.image3, image3) || other.image3 == image3)&&(identical(other.parent, parent) || other.parent == parent)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,id,orderId,image0,image1,image2,image3,parent); + +@override +String toString() { + return 'LatteImageBatch(id: $id, orderId: $orderId, image0: $image0, image1: $image1, image2: $image2, image3: $image3, parent: $parent)'; +} + + +} + +/// @nodoc +abstract mixin class _$LatteImageBatchCopyWith<$Res> implements $LatteImageBatchCopyWith<$Res> { + factory _$LatteImageBatchCopyWith(_LatteImageBatch value, $Res Function(_LatteImageBatch) _then) = __$LatteImageBatchCopyWithImpl; +@override @useResult +$Res call({ + String id, String orderId,@LatteImageConverter() LatteImage? image0,@LatteImageConverter() LatteImage? image1,@LatteImageConverter() LatteImage? image2,@LatteImageConverter() LatteImage? image3,@LatteImageBatchParentConverter() LatteImageBatchParent? parent +}); + + +@override $LatteImageCopyWith<$Res>? get image0;@override $LatteImageCopyWith<$Res>? get image1;@override $LatteImageCopyWith<$Res>? get image2;@override $LatteImageCopyWith<$Res>? get image3;@override $LatteImageBatchParentCopyWith<$Res>? get parent; + +} +/// @nodoc +class __$LatteImageBatchCopyWithImpl<$Res> + implements _$LatteImageBatchCopyWith<$Res> { + __$LatteImageBatchCopyWithImpl(this._self, this._then); + + final _LatteImageBatch _self; + final $Res Function(_LatteImageBatch) _then; + +/// Create a copy of LatteImageBatch +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? orderId = null,Object? image0 = freezed,Object? image1 = freezed,Object? image2 = freezed,Object? image3 = freezed,Object? parent = freezed,}) { + return _then(_LatteImageBatch( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String,orderId: null == orderId ? _self.orderId : orderId // ignore: cast_nullable_to_non_nullable +as String,image0: freezed == image0 ? _self.image0 : image0 // ignore: cast_nullable_to_non_nullable +as LatteImage?,image1: freezed == image1 ? _self.image1 : image1 // ignore: cast_nullable_to_non_nullable +as LatteImage?,image2: freezed == image2 ? _self.image2 : image2 // ignore: cast_nullable_to_non_nullable +as LatteImage?,image3: freezed == image3 ? _self.image3 : image3 // ignore: cast_nullable_to_non_nullable +as LatteImage?,parent: freezed == parent ? _self.parent : parent // ignore: cast_nullable_to_non_nullable +as LatteImageBatchParent?, + )); +} + +/// Create a copy of LatteImageBatch +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$LatteImageCopyWith<$Res>? get image0 { + if (_self.image0 == null) { + return null; + } + + return $LatteImageCopyWith<$Res>(_self.image0!, (value) { + return _then(_self.copyWith(image0: value)); + }); +}/// Create a copy of LatteImageBatch +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$LatteImageCopyWith<$Res>? get image1 { + if (_self.image1 == null) { + return null; + } + + return $LatteImageCopyWith<$Res>(_self.image1!, (value) { + return _then(_self.copyWith(image1: value)); + }); +}/// Create a copy of LatteImageBatch +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$LatteImageCopyWith<$Res>? get image2 { + if (_self.image2 == null) { + return null; + } + + return $LatteImageCopyWith<$Res>(_self.image2!, (value) { + return _then(_self.copyWith(image2: value)); + }); +}/// Create a copy of LatteImageBatch +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$LatteImageCopyWith<$Res>? get image3 { + if (_self.image3 == null) { + return null; + } + + return $LatteImageCopyWith<$Res>(_self.image3!, (value) { + return _then(_self.copyWith(image3: value)); + }); +}/// Create a copy of LatteImageBatch +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$LatteImageBatchParentCopyWith<$Res>? get parent { + if (_self.parent == null) { + return null; + } + + return $LatteImageBatchParentCopyWith<$Res>(_self.parent!, (value) { + return _then(_self.copyWith(parent: value)); + }); +} +} + + +/// @nodoc +mixin _$LatteImageBatchParent { + +/// Firebase Id of the [LatteImageBatch] that this image batch forked from. + String get id;/// "image0", "image1", "image2", or "image3". No other values are +/// acceptable. + String get imageIndex; +/// Create a copy of LatteImageBatchParent +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$LatteImageBatchParentCopyWith get copyWith => _$LatteImageBatchParentCopyWithImpl(this as LatteImageBatchParent, _$identity); + + /// Serializes this LatteImageBatchParent to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is LatteImageBatchParent&&(identical(other.id, id) || other.id == id)&&(identical(other.imageIndex, imageIndex) || other.imageIndex == imageIndex)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,id,imageIndex); + +@override +String toString() { + return 'LatteImageBatchParent(id: $id, imageIndex: $imageIndex)'; +} + + +} + +/// @nodoc +abstract mixin class $LatteImageBatchParentCopyWith<$Res> { + factory $LatteImageBatchParentCopyWith(LatteImageBatchParent value, $Res Function(LatteImageBatchParent) _then) = _$LatteImageBatchParentCopyWithImpl; +@useResult +$Res call({ + String id, String imageIndex +}); + + + + +} +/// @nodoc +class _$LatteImageBatchParentCopyWithImpl<$Res> + implements $LatteImageBatchParentCopyWith<$Res> { + _$LatteImageBatchParentCopyWithImpl(this._self, this._then); + + final LatteImageBatchParent _self; + final $Res Function(LatteImageBatchParent) _then; + +/// Create a copy of LatteImageBatchParent +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? imageIndex = null,}) { + return _then(_self.copyWith( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String,imageIndex: null == imageIndex ? _self.imageIndex : imageIndex // ignore: cast_nullable_to_non_nullable +as String, + )); +} + +} + + +/// Adds pattern-matching-related methods to [LatteImageBatchParent]. +extension LatteImageBatchParentPatterns on LatteImageBatchParent { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _LatteImageBatchParent value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _LatteImageBatchParent() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _LatteImageBatchParent value) $default,){ +final _that = this; +switch (_that) { +case _LatteImageBatchParent(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _LatteImageBatchParent value)? $default,){ +final _that = this; +switch (_that) { +case _LatteImageBatchParent() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String id, String imageIndex)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _LatteImageBatchParent() when $default != null: +return $default(_that.id,_that.imageIndex);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String id, String imageIndex) $default,) {final _that = this; +switch (_that) { +case _LatteImageBatchParent(): +return $default(_that.id,_that.imageIndex);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String id, String imageIndex)? $default,) {final _that = this; +switch (_that) { +case _LatteImageBatchParent() when $default != null: +return $default(_that.id,_that.imageIndex);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _LatteImageBatchParent implements LatteImageBatchParent { + const _LatteImageBatchParent({required this.id, required this.imageIndex}); + factory _LatteImageBatchParent.fromJson(Map json) => _$LatteImageBatchParentFromJson(json); + +/// Firebase Id of the [LatteImageBatch] that this image batch forked from. +@override final String id; +/// "image0", "image1", "image2", or "image3". No other values are +/// acceptable. +@override final String imageIndex; + +/// Create a copy of LatteImageBatchParent +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$LatteImageBatchParentCopyWith<_LatteImageBatchParent> get copyWith => __$LatteImageBatchParentCopyWithImpl<_LatteImageBatchParent>(this, _$identity); + +@override +Map toJson() { + return _$LatteImageBatchParentToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _LatteImageBatchParent&&(identical(other.id, id) || other.id == id)&&(identical(other.imageIndex, imageIndex) || other.imageIndex == imageIndex)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,id,imageIndex); + +@override +String toString() { + return 'LatteImageBatchParent(id: $id, imageIndex: $imageIndex)'; +} + + +} + +/// @nodoc +abstract mixin class _$LatteImageBatchParentCopyWith<$Res> implements $LatteImageBatchParentCopyWith<$Res> { + factory _$LatteImageBatchParentCopyWith(_LatteImageBatchParent value, $Res Function(_LatteImageBatchParent) _then) = __$LatteImageBatchParentCopyWithImpl; +@override @useResult +$Res call({ + String id, String imageIndex +}); + + + + +} +/// @nodoc +class __$LatteImageBatchParentCopyWithImpl<$Res> + implements _$LatteImageBatchParentCopyWith<$Res> { + __$LatteImageBatchParentCopyWithImpl(this._self, this._then); + + final _LatteImageBatchParent _self; + final $Res Function(_LatteImageBatchParent) _then; + +/// Create a copy of LatteImageBatchParent +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? imageIndex = null,}) { + return _then(_LatteImageBatchParent( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String,imageIndex: null == imageIndex ? _self.imageIndex : imageIndex // ignore: cast_nullable_to_non_nullable +as String, + )); +} + + +} + + +/// @nodoc +mixin _$LatteImage { + +/// The URL of the generated image. + String get imageUrl;/// The upgraded prompt sent to Gemini. + String get prompt;@QuestionConverter() List? get questions;/// A description of the generated image. + String get description; +/// Create a copy of LatteImage +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$LatteImageCopyWith get copyWith => _$LatteImageCopyWithImpl(this as LatteImage, _$identity); + + /// Serializes this LatteImage to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is LatteImage&&(identical(other.imageUrl, imageUrl) || other.imageUrl == imageUrl)&&(identical(other.prompt, prompt) || other.prompt == prompt)&&const DeepCollectionEquality().equals(other.questions, questions)&&(identical(other.description, description) || other.description == description)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,imageUrl,prompt,const DeepCollectionEquality().hash(questions),description); + +@override +String toString() { + return 'LatteImage(imageUrl: $imageUrl, prompt: $prompt, questions: $questions, description: $description)'; +} + + +} + +/// @nodoc +abstract mixin class $LatteImageCopyWith<$Res> { + factory $LatteImageCopyWith(LatteImage value, $Res Function(LatteImage) _then) = _$LatteImageCopyWithImpl; +@useResult +$Res call({ + String imageUrl, String prompt,@QuestionConverter() List? questions, String description +}); + + + + +} +/// @nodoc +class _$LatteImageCopyWithImpl<$Res> + implements $LatteImageCopyWith<$Res> { + _$LatteImageCopyWithImpl(this._self, this._then); + + final LatteImage _self; + final $Res Function(LatteImage) _then; + +/// Create a copy of LatteImage +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? imageUrl = null,Object? prompt = null,Object? questions = freezed,Object? description = null,}) { + return _then(_self.copyWith( +imageUrl: null == imageUrl ? _self.imageUrl : imageUrl // ignore: cast_nullable_to_non_nullable +as String,prompt: null == prompt ? _self.prompt : prompt // ignore: cast_nullable_to_non_nullable +as String,questions: freezed == questions ? _self.questions : questions // ignore: cast_nullable_to_non_nullable +as List?,description: null == description ? _self.description : description // ignore: cast_nullable_to_non_nullable +as String, + )); +} + +} + + +/// Adds pattern-matching-related methods to [LatteImage]. +extension LatteImagePatterns on LatteImage { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _LatteImage value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _LatteImage() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _LatteImage value) $default,){ +final _that = this; +switch (_that) { +case _LatteImage(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _LatteImage value)? $default,){ +final _that = this; +switch (_that) { +case _LatteImage() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String imageUrl, String prompt, @QuestionConverter() List? questions, String description)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _LatteImage() when $default != null: +return $default(_that.imageUrl,_that.prompt,_that.questions,_that.description);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String imageUrl, String prompt, @QuestionConverter() List? questions, String description) $default,) {final _that = this; +switch (_that) { +case _LatteImage(): +return $default(_that.imageUrl,_that.prompt,_that.questions,_that.description);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String imageUrl, String prompt, @QuestionConverter() List? questions, String description)? $default,) {final _that = this; +switch (_that) { +case _LatteImage() when $default != null: +return $default(_that.imageUrl,_that.prompt,_that.questions,_that.description);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _LatteImage extends LatteImage { + const _LatteImage({required this.imageUrl, required this.prompt, @QuestionConverter() required final List? questions, required this.description}): _questions = questions,super._(); + factory _LatteImage.fromJson(Map json) => _$LatteImageFromJson(json); + +/// The URL of the generated image. +@override final String imageUrl; +/// The upgraded prompt sent to Gemini. +@override final String prompt; + final List? _questions; +@override@QuestionConverter() List? get questions { + final value = _questions; + if (value == null) return null; + if (_questions is EqualUnmodifiableListView) return _questions; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(value); +} + +/// A description of the generated image. +@override final String description; + +/// Create a copy of LatteImage +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$LatteImageCopyWith<_LatteImage> get copyWith => __$LatteImageCopyWithImpl<_LatteImage>(this, _$identity); + +@override +Map toJson() { + return _$LatteImageToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _LatteImage&&(identical(other.imageUrl, imageUrl) || other.imageUrl == imageUrl)&&(identical(other.prompt, prompt) || other.prompt == prompt)&&const DeepCollectionEquality().equals(other._questions, _questions)&&(identical(other.description, description) || other.description == description)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,imageUrl,prompt,const DeepCollectionEquality().hash(_questions),description); + +@override +String toString() { + return 'LatteImage(imageUrl: $imageUrl, prompt: $prompt, questions: $questions, description: $description)'; +} + + +} + +/// @nodoc +abstract mixin class _$LatteImageCopyWith<$Res> implements $LatteImageCopyWith<$Res> { + factory _$LatteImageCopyWith(_LatteImage value, $Res Function(_LatteImage) _then) = __$LatteImageCopyWithImpl; +@override @useResult +$Res call({ + String imageUrl, String prompt,@QuestionConverter() List? questions, String description +}); + + + + +} +/// @nodoc +class __$LatteImageCopyWithImpl<$Res> + implements _$LatteImageCopyWith<$Res> { + __$LatteImageCopyWithImpl(this._self, this._then); + + final _LatteImage _self; + final $Res Function(_LatteImage) _then; + +/// Create a copy of LatteImage +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? imageUrl = null,Object? prompt = null,Object? questions = freezed,Object? description = null,}) { + return _then(_LatteImage( +imageUrl: null == imageUrl ? _self.imageUrl : imageUrl // ignore: cast_nullable_to_non_nullable +as String,prompt: null == prompt ? _self.prompt : prompt // ignore: cast_nullable_to_non_nullable +as String,questions: freezed == questions ? _self._questions : questions // ignore: cast_nullable_to_non_nullable +as List?,description: null == description ? _self.description : description // ignore: cast_nullable_to_non_nullable +as String, + )); +} + + +} + + +/// @nodoc +mixin _$RecentLatteImage { + +/// The URL of the generated image. + String get imageUrl;/// The upgraded prompt sent to Gemini. + String get prompt;/// The happy place that was used to generate the image. + String get happyPlace;/// A description of the generated image. + String get description;/// The name of the user who created this image. + String get name;/// The time the originating [LatteOrder] was submitted and this document +/// was created. + DateTime get createdAt;/// The ID of the generated image. + String? get id; +/// Create a copy of RecentLatteImage +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$RecentLatteImageCopyWith get copyWith => _$RecentLatteImageCopyWithImpl(this as RecentLatteImage, _$identity); + + /// Serializes this RecentLatteImage to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is RecentLatteImage&&(identical(other.imageUrl, imageUrl) || other.imageUrl == imageUrl)&&(identical(other.prompt, prompt) || other.prompt == prompt)&&(identical(other.happyPlace, happyPlace) || other.happyPlace == happyPlace)&&(identical(other.description, description) || other.description == description)&&(identical(other.name, name) || other.name == name)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.id, id) || other.id == id)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,imageUrl,prompt,happyPlace,description,name,createdAt,id); + +@override +String toString() { + return 'RecentLatteImage(imageUrl: $imageUrl, prompt: $prompt, happyPlace: $happyPlace, description: $description, name: $name, createdAt: $createdAt, id: $id)'; +} + + +} + +/// @nodoc +abstract mixin class $RecentLatteImageCopyWith<$Res> { + factory $RecentLatteImageCopyWith(RecentLatteImage value, $Res Function(RecentLatteImage) _then) = _$RecentLatteImageCopyWithImpl; +@useResult +$Res call({ + String imageUrl, String prompt, String happyPlace, String description, String name, DateTime createdAt, String? id +}); + + + + +} +/// @nodoc +class _$RecentLatteImageCopyWithImpl<$Res> + implements $RecentLatteImageCopyWith<$Res> { + _$RecentLatteImageCopyWithImpl(this._self, this._then); + + final RecentLatteImage _self; + final $Res Function(RecentLatteImage) _then; + +/// Create a copy of RecentLatteImage +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? imageUrl = null,Object? prompt = null,Object? happyPlace = null,Object? description = null,Object? name = null,Object? createdAt = null,Object? id = freezed,}) { + return _then(_self.copyWith( +imageUrl: null == imageUrl ? _self.imageUrl : imageUrl // ignore: cast_nullable_to_non_nullable +as String,prompt: null == prompt ? _self.prompt : prompt // ignore: cast_nullable_to_non_nullable +as String,happyPlace: null == happyPlace ? _self.happyPlace : happyPlace // ignore: cast_nullable_to_non_nullable +as String,description: null == description ? _self.description : description // ignore: cast_nullable_to_non_nullable +as String,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable +as String,createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable +as DateTime,id: freezed == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String?, + )); +} + +} + + +/// Adds pattern-matching-related methods to [RecentLatteImage]. +extension RecentLatteImagePatterns on RecentLatteImage { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _RecentLatteImage value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _RecentLatteImage() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _RecentLatteImage value) $default,){ +final _that = this; +switch (_that) { +case _RecentLatteImage(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _RecentLatteImage value)? $default,){ +final _that = this; +switch (_that) { +case _RecentLatteImage() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String imageUrl, String prompt, String happyPlace, String description, String name, DateTime createdAt, String? id)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _RecentLatteImage() when $default != null: +return $default(_that.imageUrl,_that.prompt,_that.happyPlace,_that.description,_that.name,_that.createdAt,_that.id);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String imageUrl, String prompt, String happyPlace, String description, String name, DateTime createdAt, String? id) $default,) {final _that = this; +switch (_that) { +case _RecentLatteImage(): +return $default(_that.imageUrl,_that.prompt,_that.happyPlace,_that.description,_that.name,_that.createdAt,_that.id);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String imageUrl, String prompt, String happyPlace, String description, String name, DateTime createdAt, String? id)? $default,) {final _that = this; +switch (_that) { +case _RecentLatteImage() when $default != null: +return $default(_that.imageUrl,_that.prompt,_that.happyPlace,_that.description,_that.name,_that.createdAt,_that.id);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _RecentLatteImage extends RecentLatteImage { + const _RecentLatteImage({required this.imageUrl, required this.prompt, required this.happyPlace, required this.description, required this.name, required this.createdAt, this.id}): super._(); + factory _RecentLatteImage.fromJson(Map json) => _$RecentLatteImageFromJson(json); + +/// The URL of the generated image. +@override final String imageUrl; +/// The upgraded prompt sent to Gemini. +@override final String prompt; +/// The happy place that was used to generate the image. +@override final String happyPlace; +/// A description of the generated image. +@override final String description; +/// The name of the user who created this image. +@override final String name; +/// The time the originating [LatteOrder] was submitted and this document +/// was created. +@override final DateTime createdAt; +/// The ID of the generated image. +@override final String? id; + +/// Create a copy of RecentLatteImage +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$RecentLatteImageCopyWith<_RecentLatteImage> get copyWith => __$RecentLatteImageCopyWithImpl<_RecentLatteImage>(this, _$identity); + +@override +Map toJson() { + return _$RecentLatteImageToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _RecentLatteImage&&(identical(other.imageUrl, imageUrl) || other.imageUrl == imageUrl)&&(identical(other.prompt, prompt) || other.prompt == prompt)&&(identical(other.happyPlace, happyPlace) || other.happyPlace == happyPlace)&&(identical(other.description, description) || other.description == description)&&(identical(other.name, name) || other.name == name)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.id, id) || other.id == id)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,imageUrl,prompt,happyPlace,description,name,createdAt,id); + +@override +String toString() { + return 'RecentLatteImage(imageUrl: $imageUrl, prompt: $prompt, happyPlace: $happyPlace, description: $description, name: $name, createdAt: $createdAt, id: $id)'; +} + + +} + +/// @nodoc +abstract mixin class _$RecentLatteImageCopyWith<$Res> implements $RecentLatteImageCopyWith<$Res> { + factory _$RecentLatteImageCopyWith(_RecentLatteImage value, $Res Function(_RecentLatteImage) _then) = __$RecentLatteImageCopyWithImpl; +@override @useResult +$Res call({ + String imageUrl, String prompt, String happyPlace, String description, String name, DateTime createdAt, String? id +}); + + + + +} +/// @nodoc +class __$RecentLatteImageCopyWithImpl<$Res> + implements _$RecentLatteImageCopyWith<$Res> { + __$RecentLatteImageCopyWithImpl(this._self, this._then); + + final _RecentLatteImage _self; + final $Res Function(_RecentLatteImage) _then; + +/// Create a copy of RecentLatteImage +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? imageUrl = null,Object? prompt = null,Object? happyPlace = null,Object? description = null,Object? name = null,Object? createdAt = null,Object? id = freezed,}) { + return _then(_RecentLatteImage( +imageUrl: null == imageUrl ? _self.imageUrl : imageUrl // ignore: cast_nullable_to_non_nullable +as String,prompt: null == prompt ? _self.prompt : prompt // ignore: cast_nullable_to_non_nullable +as String,happyPlace: null == happyPlace ? _self.happyPlace : happyPlace // ignore: cast_nullable_to_non_nullable +as String,description: null == description ? _self.description : description // ignore: cast_nullable_to_non_nullable +as String,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable +as String,createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable +as DateTime,id: freezed == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String?, + )); +} + + +} + +// dart format on diff --git a/genlatte/genlatte-data/lib/src/models/latte_image.g.dart b/genlatte/genlatte-data/lib/src/models/latte_image.g.dart new file mode 100644 index 0000000..a952e63 --- /dev/null +++ b/genlatte/genlatte-data/lib/src/models/latte_image.g.dart @@ -0,0 +1,129 @@ +// Copyright 2014 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. + +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'latte_image.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_LatteImageBatch _$LatteImageBatchFromJson(Map json) => + _LatteImageBatch( + id: json['id'] as String, + orderId: json['orderId'] as String, + image0: _$JsonConverterFromJson, LatteImage>( + json['image0'], + const LatteImageConverter().fromJson, + ), + image1: _$JsonConverterFromJson, LatteImage>( + json['image1'], + const LatteImageConverter().fromJson, + ), + image2: _$JsonConverterFromJson, LatteImage>( + json['image2'], + const LatteImageConverter().fromJson, + ), + image3: _$JsonConverterFromJson, LatteImage>( + json['image3'], + const LatteImageConverter().fromJson, + ), + parent: + _$JsonConverterFromJson, LatteImageBatchParent>( + json['parent'], + const LatteImageBatchParentConverter().fromJson, + ), + ); + +Map _$LatteImageBatchToJson( + _LatteImageBatch instance, +) => { + 'id': instance.id, + 'orderId': instance.orderId, + 'image0': _$JsonConverterToJson, LatteImage>( + instance.image0, + const LatteImageConverter().toJson, + ), + 'image1': _$JsonConverterToJson, LatteImage>( + instance.image1, + const LatteImageConverter().toJson, + ), + 'image2': _$JsonConverterToJson, LatteImage>( + instance.image2, + const LatteImageConverter().toJson, + ), + 'image3': _$JsonConverterToJson, LatteImage>( + instance.image3, + const LatteImageConverter().toJson, + ), + 'parent': _$JsonConverterToJson, LatteImageBatchParent>( + instance.parent, + const LatteImageBatchParentConverter().toJson, + ), +}; + +Value? _$JsonConverterFromJson( + Object? json, + Value? Function(Json json) fromJson, +) => json == null ? null : fromJson(json as Json); + +Json? _$JsonConverterToJson( + Value? value, + Json? Function(Value value) toJson, +) => value == null ? null : toJson(value); + +_LatteImageBatchParent _$LatteImageBatchParentFromJson( + Map json, +) => _LatteImageBatchParent( + id: json['id'] as String, + imageIndex: json['imageIndex'] as String, +); + +Map _$LatteImageBatchParentToJson( + _LatteImageBatchParent instance, +) => {'id': instance.id, 'imageIndex': instance.imageIndex}; + +_LatteImage _$LatteImageFromJson(Map json) => _LatteImage( + imageUrl: json['imageUrl'] as String, + prompt: json['prompt'] as String, + questions: (json['questions'] as List?) + ?.map( + (e) => const QuestionConverter().fromJson(e as Map), + ) + .toList(), + description: json['description'] as String, +); + +Map _$LatteImageToJson(_LatteImage instance) => + { + 'imageUrl': instance.imageUrl, + 'prompt': instance.prompt, + 'questions': instance.questions + ?.map(const QuestionConverter().toJson) + .toList(), + 'description': instance.description, + }; + +_RecentLatteImage _$RecentLatteImageFromJson(Map json) => + _RecentLatteImage( + imageUrl: json['imageUrl'] as String, + prompt: json['prompt'] as String, + happyPlace: json['happyPlace'] as String, + description: json['description'] as String, + name: json['name'] as String, + createdAt: DateTime.parse(json['createdAt'] as String), + id: json['id'] as String?, + ); + +Map _$RecentLatteImageToJson(_RecentLatteImage instance) => + { + 'imageUrl': instance.imageUrl, + 'prompt': instance.prompt, + 'happyPlace': instance.happyPlace, + 'description': instance.description, + 'name': instance.name, + 'createdAt': instance.createdAt.toIso8601String(), + 'id': instance.id, + }; diff --git a/genlatte/genlatte-data/lib/src/models/latte_options.dart b/genlatte/genlatte-data/lib/src/models/latte_options.dart new file mode 100644 index 0000000..1e43c80 --- /dev/null +++ b/genlatte/genlatte-data/lib/src/models/latte_options.dart @@ -0,0 +1,68 @@ +// Copyright 2014 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. + +/// @docImport 'package:genlatte_data/src/models/latte_order.dart'; +library; + +import 'package:data_layer/data_layer.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'latte_options.freezed.dart'; +part 'latte_options.g.dart'; + +/// Milk and Sweetener options for a [LatteOrder]. +@freezed +abstract class LatteOptions with _$LatteOptions { + /// Instantiates a [LatteOptions]. + const factory LatteOptions({ + required String id, + @LatteOptionConverter() required List values, + }) = _LatteOptions; + + const LatteOptions._(); + + /// Json deserializer for [LatteOptions]. + factory LatteOptions.fromJson(Map json) => + _$LatteOptionsFromJson(json); + + /// Data Layer bindings for [LatteOptions]. + static Bindings bindings = Bindings( + fromJson: LatteOptions.fromJson, + toJson: (obj) => obj.toJson(), + getId: (obj) => obj.id, + getDetailUrl: (id) => ApiUrl(path: 'latteOptions/$id'), + getListUrl: () => const ApiUrl(path: 'latteOptions'), + ); +} + +/// An entry within [LatteOptions]. +/// +/// For example, if the surrounding [LatteOptions] is for "milk", the values +/// could be "whole", "skim", "oat". +@Freezed() +abstract class LatteOption with _$LatteOption { + /// Instantiates a [LatteOption]. + const factory LatteOption({ + required String name, + @Default(true) bool isAvailable, + }) = _LatteOption; + + const LatteOption._(); + + /// Json deserializer for [LatteOption]. + factory LatteOption.fromJson(Map json) => + _$LatteOptionFromJson(json); +} + +/// JsonConverter for [LatteOption] used in [LatteOptions.values]. +class LatteOptionConverter extends JsonConverter { + /// Instantiates a [LatteOptionConverter]. + const LatteOptionConverter(); + + @override + LatteOption fromJson(Json json) => LatteOption.fromJson(json); + + @override + Json toJson(LatteOption obj) => obj.toJson(); +} diff --git a/genlatte/genlatte-data/lib/src/models/latte_options.freezed.dart b/genlatte/genlatte-data/lib/src/models/latte_options.freezed.dart new file mode 100644 index 0000000..62e47d3 --- /dev/null +++ b/genlatte/genlatte-data/lib/src/models/latte_options.freezed.dart @@ -0,0 +1,556 @@ +// Copyright 2014 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. + +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'latte_options.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; + +/// @nodoc +mixin _$LatteOptions { + + String get id;@LatteOptionConverter() List get values; +/// Create a copy of LatteOptions +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$LatteOptionsCopyWith get copyWith => _$LatteOptionsCopyWithImpl(this as LatteOptions, _$identity); + + /// Serializes this LatteOptions to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is LatteOptions&&(identical(other.id, id) || other.id == id)&&const DeepCollectionEquality().equals(other.values, values)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,id,const DeepCollectionEquality().hash(values)); + +@override +String toString() { + return 'LatteOptions(id: $id, values: $values)'; +} + + +} + +/// @nodoc +abstract mixin class $LatteOptionsCopyWith<$Res> { + factory $LatteOptionsCopyWith(LatteOptions value, $Res Function(LatteOptions) _then) = _$LatteOptionsCopyWithImpl; +@useResult +$Res call({ + String id,@LatteOptionConverter() List values +}); + + + + +} +/// @nodoc +class _$LatteOptionsCopyWithImpl<$Res> + implements $LatteOptionsCopyWith<$Res> { + _$LatteOptionsCopyWithImpl(this._self, this._then); + + final LatteOptions _self; + final $Res Function(LatteOptions) _then; + +/// Create a copy of LatteOptions +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? values = null,}) { + return _then(_self.copyWith( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String,values: null == values ? _self.values : values // ignore: cast_nullable_to_non_nullable +as List, + )); +} + +} + + +/// Adds pattern-matching-related methods to [LatteOptions]. +extension LatteOptionsPatterns on LatteOptions { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _LatteOptions value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _LatteOptions() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _LatteOptions value) $default,){ +final _that = this; +switch (_that) { +case _LatteOptions(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _LatteOptions value)? $default,){ +final _that = this; +switch (_that) { +case _LatteOptions() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String id, @LatteOptionConverter() List values)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _LatteOptions() when $default != null: +return $default(_that.id,_that.values);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String id, @LatteOptionConverter() List values) $default,) {final _that = this; +switch (_that) { +case _LatteOptions(): +return $default(_that.id,_that.values);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String id, @LatteOptionConverter() List values)? $default,) {final _that = this; +switch (_that) { +case _LatteOptions() when $default != null: +return $default(_that.id,_that.values);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _LatteOptions extends LatteOptions { + const _LatteOptions({required this.id, @LatteOptionConverter() required final List values}): _values = values,super._(); + factory _LatteOptions.fromJson(Map json) => _$LatteOptionsFromJson(json); + +@override final String id; + final List _values; +@override@LatteOptionConverter() List get values { + if (_values is EqualUnmodifiableListView) return _values; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_values); +} + + +/// Create a copy of LatteOptions +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$LatteOptionsCopyWith<_LatteOptions> get copyWith => __$LatteOptionsCopyWithImpl<_LatteOptions>(this, _$identity); + +@override +Map toJson() { + return _$LatteOptionsToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _LatteOptions&&(identical(other.id, id) || other.id == id)&&const DeepCollectionEquality().equals(other._values, _values)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,id,const DeepCollectionEquality().hash(_values)); + +@override +String toString() { + return 'LatteOptions(id: $id, values: $values)'; +} + + +} + +/// @nodoc +abstract mixin class _$LatteOptionsCopyWith<$Res> implements $LatteOptionsCopyWith<$Res> { + factory _$LatteOptionsCopyWith(_LatteOptions value, $Res Function(_LatteOptions) _then) = __$LatteOptionsCopyWithImpl; +@override @useResult +$Res call({ + String id,@LatteOptionConverter() List values +}); + + + + +} +/// @nodoc +class __$LatteOptionsCopyWithImpl<$Res> + implements _$LatteOptionsCopyWith<$Res> { + __$LatteOptionsCopyWithImpl(this._self, this._then); + + final _LatteOptions _self; + final $Res Function(_LatteOptions) _then; + +/// Create a copy of LatteOptions +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? values = null,}) { + return _then(_LatteOptions( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String,values: null == values ? _self._values : values // ignore: cast_nullable_to_non_nullable +as List, + )); +} + + +} + + +/// @nodoc +mixin _$LatteOption { + + String get name; bool get isAvailable; +/// Create a copy of LatteOption +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$LatteOptionCopyWith get copyWith => _$LatteOptionCopyWithImpl(this as LatteOption, _$identity); + + /// Serializes this LatteOption to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is LatteOption&&(identical(other.name, name) || other.name == name)&&(identical(other.isAvailable, isAvailable) || other.isAvailable == isAvailable)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,name,isAvailable); + +@override +String toString() { + return 'LatteOption(name: $name, isAvailable: $isAvailable)'; +} + + +} + +/// @nodoc +abstract mixin class $LatteOptionCopyWith<$Res> { + factory $LatteOptionCopyWith(LatteOption value, $Res Function(LatteOption) _then) = _$LatteOptionCopyWithImpl; +@useResult +$Res call({ + String name, bool isAvailable +}); + + + + +} +/// @nodoc +class _$LatteOptionCopyWithImpl<$Res> + implements $LatteOptionCopyWith<$Res> { + _$LatteOptionCopyWithImpl(this._self, this._then); + + final LatteOption _self; + final $Res Function(LatteOption) _then; + +/// Create a copy of LatteOption +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? name = null,Object? isAvailable = null,}) { + return _then(_self.copyWith( +name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable +as String,isAvailable: null == isAvailable ? _self.isAvailable : isAvailable // ignore: cast_nullable_to_non_nullable +as bool, + )); +} + +} + + +/// Adds pattern-matching-related methods to [LatteOption]. +extension LatteOptionPatterns on LatteOption { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _LatteOption value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _LatteOption() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _LatteOption value) $default,){ +final _that = this; +switch (_that) { +case _LatteOption(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _LatteOption value)? $default,){ +final _that = this; +switch (_that) { +case _LatteOption() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String name, bool isAvailable)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _LatteOption() when $default != null: +return $default(_that.name,_that.isAvailable);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String name, bool isAvailable) $default,) {final _that = this; +switch (_that) { +case _LatteOption(): +return $default(_that.name,_that.isAvailable);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String name, bool isAvailable)? $default,) {final _that = this; +switch (_that) { +case _LatteOption() when $default != null: +return $default(_that.name,_that.isAvailable);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _LatteOption extends LatteOption { + const _LatteOption({required this.name, this.isAvailable = true}): super._(); + factory _LatteOption.fromJson(Map json) => _$LatteOptionFromJson(json); + +@override final String name; +@override@JsonKey() final bool isAvailable; + +/// Create a copy of LatteOption +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$LatteOptionCopyWith<_LatteOption> get copyWith => __$LatteOptionCopyWithImpl<_LatteOption>(this, _$identity); + +@override +Map toJson() { + return _$LatteOptionToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _LatteOption&&(identical(other.name, name) || other.name == name)&&(identical(other.isAvailable, isAvailable) || other.isAvailable == isAvailable)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,name,isAvailable); + +@override +String toString() { + return 'LatteOption(name: $name, isAvailable: $isAvailable)'; +} + + +} + +/// @nodoc +abstract mixin class _$LatteOptionCopyWith<$Res> implements $LatteOptionCopyWith<$Res> { + factory _$LatteOptionCopyWith(_LatteOption value, $Res Function(_LatteOption) _then) = __$LatteOptionCopyWithImpl; +@override @useResult +$Res call({ + String name, bool isAvailable +}); + + + + +} +/// @nodoc +class __$LatteOptionCopyWithImpl<$Res> + implements _$LatteOptionCopyWith<$Res> { + __$LatteOptionCopyWithImpl(this._self, this._then); + + final _LatteOption _self; + final $Res Function(_LatteOption) _then; + +/// Create a copy of LatteOption +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? name = null,Object? isAvailable = null,}) { + return _then(_LatteOption( +name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable +as String,isAvailable: null == isAvailable ? _self.isAvailable : isAvailable // ignore: cast_nullable_to_non_nullable +as bool, + )); +} + + +} + +// dart format on diff --git a/genlatte/genlatte-data/lib/src/models/latte_options.g.dart b/genlatte/genlatte-data/lib/src/models/latte_options.g.dart new file mode 100644 index 0000000..f5237b8 --- /dev/null +++ b/genlatte/genlatte-data/lib/src/models/latte_options.g.dart @@ -0,0 +1,41 @@ +// Copyright 2014 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. + +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'latte_options.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_LatteOptions _$LatteOptionsFromJson(Map json) => + _LatteOptions( + id: json['id'] as String, + values: (json['values'] as List) + .map( + (e) => const LatteOptionConverter().fromJson( + e as Map, + ), + ) + .toList(), + ); + +Map _$LatteOptionsToJson( + _LatteOptions instance, +) => { + 'id': instance.id, + 'values': instance.values.map(const LatteOptionConverter().toJson).toList(), +}; + +_LatteOption _$LatteOptionFromJson(Map json) => _LatteOption( + name: json['name'] as String, + isAvailable: json['isAvailable'] as bool? ?? true, +); + +Map _$LatteOptionToJson(_LatteOption instance) => + { + 'name': instance.name, + 'isAvailable': instance.isAvailable, + }; diff --git a/genlatte/genlatte-data/lib/src/models/latte_order.dart b/genlatte/genlatte-data/lib/src/models/latte_order.dart new file mode 100644 index 0000000..727d314 --- /dev/null +++ b/genlatte/genlatte-data/lib/src/models/latte_order.dart @@ -0,0 +1,232 @@ +// Copyright 2014 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:data_layer/data_layer.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'latte_order.freezed.dart'; +part 'latte_order.g.dart'; + +/// One person's coffee order. Only contains fields the user may freely update. +/// Once a person +@freezed +abstract class LatteOrder with _$LatteOrder { + /// Creates a new [LatteOrder]. + const factory LatteOrder({ + /// Firestore-generated document Id. Not a user-friendly identifier. + String? id, + + /// The name of the person ordering. + String? name, + + /// The coffee's milk type. + /// + /// Null value for milk is not possible for a valid order, but is the + /// default order because the user must select a milk (as opposed to having + /// a default milk that they might overlook changing). + String? milk, + + /// The coffee's sweetener type, if any. + /// + /// Null value for sweetener IS possible because no sweetener is allowed. + String? sweetener, + + /// The prompt for the user's generated image. + String? happyPlace, + }) = _LatteOrder; + + const LatteOrder._(); + + /// Json deserializer for [LatteOrder]. + factory LatteOrder.fromJson(Map json) => + _$LatteOrderFromJson(json); + + /// Data Layer bindings for [LatteOrder]. + static Bindings bindings = Bindings( + fromJson: LatteOrder.fromJson, + toJson: (obj) => obj.toJson(), + getId: (obj) => obj.id, + getDetailUrl: (id) => ApiUrl(path: 'latteOrders/$id'), + getListUrl: () => const ApiUrl(path: 'latteOrders'), + ); +} + +/// Server-controlled information about a person's order which is not directly +/// editable by the user. Automated processes advance these values as the order +/// progresses through the pipeline. +/// +/// Security rules prevent this document from being written to by the client. +@freezed +abstract class LatteOrderMetadata with _$LatteOrderMetadata { + /// Creates a new [LatteOrderMetadata]. + const factory LatteOrderMetadata({ + /// Firestore-generated document Id. Not a user-friendly identifier. + /// This value is guaranteed to be the same as the corresponding + /// [LatteOrder.id]. + String? id, + + /// Sequential order number, set by the server and visible in the queue. + int? orderNumber, + + /// Gemini sets this to false or true upon moderation. Baristas optionally + /// set this to true when advancing an order's status to + /// [LatteOrderStatus.validated]. Additionally, a value of `true` is + /// required for this user's name to be shown on the big boards. + bool? isNameApproved, + + /// Gemini sets this to false or true upon moderation. Image generation is + /// gated by Gemini setting this value to `true`. Later, baristas may + /// override this to `false` when advancing an order's status to + /// [LatteOrderStatus.validated]. If a barista does this, the user will + /// receive a fallback image on their latte. + bool? isHappyPlaceApproved, + + /// The reason Gemini rejected the user's happy place prompt. If the image + /// was moderated by the barista, then this value should say + /// "barista_moderation". + String? happyPlaceModerationReason, + + /// Set to true once a barista provides final manual approval of the image. + /// This value must be true for an [LatteOrder] to advance to + /// [LatteOrderStatus.inProgress]. + bool? isImageApproved, + + /// The active image batch. + String? imageBatchId, + + /// Set once the user has committed to an image. + String? imageUrl, + + /// The reason an order was sent back from [LatteOrderStatus.submitted] to + /// [LatteOrderStatus.configuring] instead of being accepted and advanced to + /// [LatteOrderStatus.validated]. + // LatteOrderValidationError? validationError, + + /// {@macro LatteOrderStatus} + @Default(LatteOrderStatus.configuring) LatteOrderStatus status, + + /// Set to non-null once a barista claims the order. + String? baristaId, + + /// Set once the order status reaches [LatteOrderStatus.submitted]. + DateTime? orderSubmittedTime, + + /// Set once the order status reaches [LatteOrderStatus.completed]. + DateTime? completionTime, + }) = _LatteOrderMetadata; + + const LatteOrderMetadata._(); + + /// Json deserializer for [LatteOrderMetadata]. + factory LatteOrderMetadata.fromJson(Map json) => + _$LatteOrderMetadataFromJson(json); + + /// Data Layer bindings for [LatteOrderMetadata]. + static Bindings bindings = Bindings( + fromJson: LatteOrderMetadata.fromJson, + toJson: (obj) => obj.toJson(), + getId: (obj) => obj.id, + getDetailUrl: (id) => ApiUrl(path: 'latteOrderMetadata/$id'), + getListUrl: () => const ApiUrl(path: 'latteOrderMetadata'), + ); + + /// Validates that the order and metadata are valid for the given status. + /// + /// The idea is that the Order and its metadata have just been updated, + /// presumably including a new `metadata.status` value, and we want to make + /// sure that everything is as expected before saving this new value. A + /// non-null return value from this method should cause the calling code to + /// abort any save operations and return to the previous [LatteOrder] object. + LatteOrderValidationError? validate(LatteOrder order) { + switch (status) { + case .configuring: + return null; + case .submitted: + if (order.milk == null) { + return .missingMilk; + } + if (order.sweetener == null) { + return .missingSweetener; + } + if (imageUrl == null) { + return .imageNotSet; + } + return null; + case .validated || .inProgress || .completed || .archived: + return null; + } + } +} + +/// Container for a [LatteOrder] and [LatteOrderMetadata]. This is not a +/// server-side construct and only exists on the client to pair a related order +/// and its metadata. +@freezed +abstract class Latte with _$Latte { + /// Creates a new [Latte]. + const factory Latte({ + required LatteOrder order, + required LatteOrderMetadata metadata, + }) = _Latte; + + const Latte._(); + + /// Json deserializer for [Latte]. + factory Latte.fromJson(Map json) => _$LatteFromJson(json); +} + +/// {@template LatteOrderStatus} +/// The delivery progress of an [LatteOrder]. +/// {@endtemplate} +enum LatteOrderStatus { + /// The order is in the database but not yet verified and submitted. These + /// orders are not visible to baristas. + configuring, + + /// Order now successfully submitted, but not yet validated. + submitted, + + /// Order has been accepted by the server but is not yet picked up by a + /// barista. + validated, + + /// A barista has started working on the order. + inProgress, + + /// The order has been completed and is ready for pickup. + completed, + + /// The order has been completed a long time ago and we should not + /// be downloading it from the repository in most cases. + archived, +} + +/// Ways an [LatteOrder] can fail validation. +enum LatteOrderValidationError { + /// The order is missing a milk type but attempted to enter status + /// [LatteOrderStatus.submitted]. + missingMilk, + + /// The latte order is missing a sweetener but attempted to enter status + /// [LatteOrderStatus.submitted]. + missingSweetener, + + /// The order image has not been set but attempted to enter status + /// [LatteOrderStatus.submitted]. + imageNotSet, +} + +/// Operations for lists of [LatteOrderMetadata] objects. +extension LatteOrderMetadataList on List { + /// Converts the list of orders into a map keyed by order id. + Map toIdsMap() { + return fold>( + {}, + (map, order) { + map[order.id!] = order; + return map; + }, + ); + } +} diff --git a/genlatte/genlatte-data/lib/src/models/latte_order.freezed.dart b/genlatte/genlatte-data/lib/src/models/latte_order.freezed.dart new file mode 100644 index 0000000..b6db760 --- /dev/null +++ b/genlatte/genlatte-data/lib/src/models/latte_order.freezed.dart @@ -0,0 +1,971 @@ +// Copyright 2014 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. + +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'latte_order.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; + +/// @nodoc +mixin _$LatteOrder { + +/// Firestore-generated document Id. Not a user-friendly identifier. + String? get id;/// The name of the person ordering. + String? get name;/// The coffee's milk type. +/// +/// Null value for milk is not possible for a valid order, but is the +/// default order because the user must select a milk (as opposed to having +/// a default milk that they might overlook changing). + String? get milk;/// The coffee's sweetener type, if any. +/// +/// Null value for sweetener IS possible because no sweetener is allowed. + String? get sweetener;/// The prompt for the user's generated image. + String? get happyPlace; +/// Create a copy of LatteOrder +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$LatteOrderCopyWith get copyWith => _$LatteOrderCopyWithImpl(this as LatteOrder, _$identity); + + /// Serializes this LatteOrder to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is LatteOrder&&(identical(other.id, id) || other.id == id)&&(identical(other.name, name) || other.name == name)&&(identical(other.milk, milk) || other.milk == milk)&&(identical(other.sweetener, sweetener) || other.sweetener == sweetener)&&(identical(other.happyPlace, happyPlace) || other.happyPlace == happyPlace)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,id,name,milk,sweetener,happyPlace); + +@override +String toString() { + return 'LatteOrder(id: $id, name: $name, milk: $milk, sweetener: $sweetener, happyPlace: $happyPlace)'; +} + + +} + +/// @nodoc +abstract mixin class $LatteOrderCopyWith<$Res> { + factory $LatteOrderCopyWith(LatteOrder value, $Res Function(LatteOrder) _then) = _$LatteOrderCopyWithImpl; +@useResult +$Res call({ + String? id, String? name, String? milk, String? sweetener, String? happyPlace +}); + + + + +} +/// @nodoc +class _$LatteOrderCopyWithImpl<$Res> + implements $LatteOrderCopyWith<$Res> { + _$LatteOrderCopyWithImpl(this._self, this._then); + + final LatteOrder _self; + final $Res Function(LatteOrder) _then; + +/// Create a copy of LatteOrder +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? id = freezed,Object? name = freezed,Object? milk = freezed,Object? sweetener = freezed,Object? happyPlace = freezed,}) { + return _then(_self.copyWith( +id: freezed == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String?,name: freezed == name ? _self.name : name // ignore: cast_nullable_to_non_nullable +as String?,milk: freezed == milk ? _self.milk : milk // ignore: cast_nullable_to_non_nullable +as String?,sweetener: freezed == sweetener ? _self.sweetener : sweetener // ignore: cast_nullable_to_non_nullable +as String?,happyPlace: freezed == happyPlace ? _self.happyPlace : happyPlace // ignore: cast_nullable_to_non_nullable +as String?, + )); +} + +} + + +/// Adds pattern-matching-related methods to [LatteOrder]. +extension LatteOrderPatterns on LatteOrder { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _LatteOrder value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _LatteOrder() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _LatteOrder value) $default,){ +final _that = this; +switch (_that) { +case _LatteOrder(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _LatteOrder value)? $default,){ +final _that = this; +switch (_that) { +case _LatteOrder() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String? id, String? name, String? milk, String? sweetener, String? happyPlace)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _LatteOrder() when $default != null: +return $default(_that.id,_that.name,_that.milk,_that.sweetener,_that.happyPlace);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String? id, String? name, String? milk, String? sweetener, String? happyPlace) $default,) {final _that = this; +switch (_that) { +case _LatteOrder(): +return $default(_that.id,_that.name,_that.milk,_that.sweetener,_that.happyPlace);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String? id, String? name, String? milk, String? sweetener, String? happyPlace)? $default,) {final _that = this; +switch (_that) { +case _LatteOrder() when $default != null: +return $default(_that.id,_that.name,_that.milk,_that.sweetener,_that.happyPlace);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _LatteOrder extends LatteOrder { + const _LatteOrder({this.id, this.name, this.milk, this.sweetener, this.happyPlace}): super._(); + factory _LatteOrder.fromJson(Map json) => _$LatteOrderFromJson(json); + +/// Firestore-generated document Id. Not a user-friendly identifier. +@override final String? id; +/// The name of the person ordering. +@override final String? name; +/// The coffee's milk type. +/// +/// Null value for milk is not possible for a valid order, but is the +/// default order because the user must select a milk (as opposed to having +/// a default milk that they might overlook changing). +@override final String? milk; +/// The coffee's sweetener type, if any. +/// +/// Null value for sweetener IS possible because no sweetener is allowed. +@override final String? sweetener; +/// The prompt for the user's generated image. +@override final String? happyPlace; + +/// Create a copy of LatteOrder +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$LatteOrderCopyWith<_LatteOrder> get copyWith => __$LatteOrderCopyWithImpl<_LatteOrder>(this, _$identity); + +@override +Map toJson() { + return _$LatteOrderToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _LatteOrder&&(identical(other.id, id) || other.id == id)&&(identical(other.name, name) || other.name == name)&&(identical(other.milk, milk) || other.milk == milk)&&(identical(other.sweetener, sweetener) || other.sweetener == sweetener)&&(identical(other.happyPlace, happyPlace) || other.happyPlace == happyPlace)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,id,name,milk,sweetener,happyPlace); + +@override +String toString() { + return 'LatteOrder(id: $id, name: $name, milk: $milk, sweetener: $sweetener, happyPlace: $happyPlace)'; +} + + +} + +/// @nodoc +abstract mixin class _$LatteOrderCopyWith<$Res> implements $LatteOrderCopyWith<$Res> { + factory _$LatteOrderCopyWith(_LatteOrder value, $Res Function(_LatteOrder) _then) = __$LatteOrderCopyWithImpl; +@override @useResult +$Res call({ + String? id, String? name, String? milk, String? sweetener, String? happyPlace +}); + + + + +} +/// @nodoc +class __$LatteOrderCopyWithImpl<$Res> + implements _$LatteOrderCopyWith<$Res> { + __$LatteOrderCopyWithImpl(this._self, this._then); + + final _LatteOrder _self; + final $Res Function(_LatteOrder) _then; + +/// Create a copy of LatteOrder +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? id = freezed,Object? name = freezed,Object? milk = freezed,Object? sweetener = freezed,Object? happyPlace = freezed,}) { + return _then(_LatteOrder( +id: freezed == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String?,name: freezed == name ? _self.name : name // ignore: cast_nullable_to_non_nullable +as String?,milk: freezed == milk ? _self.milk : milk // ignore: cast_nullable_to_non_nullable +as String?,sweetener: freezed == sweetener ? _self.sweetener : sweetener // ignore: cast_nullable_to_non_nullable +as String?,happyPlace: freezed == happyPlace ? _self.happyPlace : happyPlace // ignore: cast_nullable_to_non_nullable +as String?, + )); +} + + +} + + +/// @nodoc +mixin _$LatteOrderMetadata { + +/// Firestore-generated document Id. Not a user-friendly identifier. +/// This value is guaranteed to be the same as the corresponding +/// [LatteOrder.id]. + String? get id;/// Sequential order number, set by the server and visible in the queue. + int? get orderNumber;/// Gemini sets this to false or true upon moderation. Baristas optionally +/// set this to true when advancing an order's status to +/// [LatteOrderStatus.validated]. Additionally, a value of `true` is +/// required for this user's name to be shown on the big boards. + bool? get isNameApproved;/// Gemini sets this to false or true upon moderation. Image generation is +/// gated by Gemini setting this value to `true`. Later, baristas may +/// override this to `false` when advancing an order's status to +/// [LatteOrderStatus.validated]. If a barista does this, the user will +/// receive a fallback image on their latte. + bool? get isHappyPlaceApproved;/// The reason Gemini rejected the user's happy place prompt. If the image +/// was moderated by the barista, then this value should say +/// "barista_moderation". + String? get happyPlaceModerationReason;/// Set to true once a barista provides final manual approval of the image. +/// This value must be true for an [LatteOrder] to advance to +/// [LatteOrderStatus.inProgress]. + bool? get isImageApproved;/// The active image batch. + String? get imageBatchId;/// Set once the user has committed to an image. + String? get imageUrl;/// The reason an order was sent back from [LatteOrderStatus.submitted] to +/// [LatteOrderStatus.configuring] instead of being accepted and advanced to +/// [LatteOrderStatus.validated]. +// LatteOrderValidationError? validationError, +/// {@macro LatteOrderStatus} + LatteOrderStatus get status;/// Set to non-null once a barista claims the order. + String? get baristaId;/// Set once the order status reaches [LatteOrderStatus.submitted]. + DateTime? get orderSubmittedTime;/// Set once the order status reaches [LatteOrderStatus.completed]. + DateTime? get completionTime; +/// Create a copy of LatteOrderMetadata +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$LatteOrderMetadataCopyWith get copyWith => _$LatteOrderMetadataCopyWithImpl(this as LatteOrderMetadata, _$identity); + + /// Serializes this LatteOrderMetadata to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is LatteOrderMetadata&&(identical(other.id, id) || other.id == id)&&(identical(other.orderNumber, orderNumber) || other.orderNumber == orderNumber)&&(identical(other.isNameApproved, isNameApproved) || other.isNameApproved == isNameApproved)&&(identical(other.isHappyPlaceApproved, isHappyPlaceApproved) || other.isHappyPlaceApproved == isHappyPlaceApproved)&&(identical(other.happyPlaceModerationReason, happyPlaceModerationReason) || other.happyPlaceModerationReason == happyPlaceModerationReason)&&(identical(other.isImageApproved, isImageApproved) || other.isImageApproved == isImageApproved)&&(identical(other.imageBatchId, imageBatchId) || other.imageBatchId == imageBatchId)&&(identical(other.imageUrl, imageUrl) || other.imageUrl == imageUrl)&&(identical(other.status, status) || other.status == status)&&(identical(other.baristaId, baristaId) || other.baristaId == baristaId)&&(identical(other.orderSubmittedTime, orderSubmittedTime) || other.orderSubmittedTime == orderSubmittedTime)&&(identical(other.completionTime, completionTime) || other.completionTime == completionTime)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,id,orderNumber,isNameApproved,isHappyPlaceApproved,happyPlaceModerationReason,isImageApproved,imageBatchId,imageUrl,status,baristaId,orderSubmittedTime,completionTime); + +@override +String toString() { + return 'LatteOrderMetadata(id: $id, orderNumber: $orderNumber, isNameApproved: $isNameApproved, isHappyPlaceApproved: $isHappyPlaceApproved, happyPlaceModerationReason: $happyPlaceModerationReason, isImageApproved: $isImageApproved, imageBatchId: $imageBatchId, imageUrl: $imageUrl, status: $status, baristaId: $baristaId, orderSubmittedTime: $orderSubmittedTime, completionTime: $completionTime)'; +} + + +} + +/// @nodoc +abstract mixin class $LatteOrderMetadataCopyWith<$Res> { + factory $LatteOrderMetadataCopyWith(LatteOrderMetadata value, $Res Function(LatteOrderMetadata) _then) = _$LatteOrderMetadataCopyWithImpl; +@useResult +$Res call({ + String? id, int? orderNumber, bool? isNameApproved, bool? isHappyPlaceApproved, String? happyPlaceModerationReason, bool? isImageApproved, String? imageBatchId, String? imageUrl, LatteOrderStatus status, String? baristaId, DateTime? orderSubmittedTime, DateTime? completionTime +}); + + + + +} +/// @nodoc +class _$LatteOrderMetadataCopyWithImpl<$Res> + implements $LatteOrderMetadataCopyWith<$Res> { + _$LatteOrderMetadataCopyWithImpl(this._self, this._then); + + final LatteOrderMetadata _self; + final $Res Function(LatteOrderMetadata) _then; + +/// Create a copy of LatteOrderMetadata +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? id = freezed,Object? orderNumber = freezed,Object? isNameApproved = freezed,Object? isHappyPlaceApproved = freezed,Object? happyPlaceModerationReason = freezed,Object? isImageApproved = freezed,Object? imageBatchId = freezed,Object? imageUrl = freezed,Object? status = null,Object? baristaId = freezed,Object? orderSubmittedTime = freezed,Object? completionTime = freezed,}) { + return _then(_self.copyWith( +id: freezed == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String?,orderNumber: freezed == orderNumber ? _self.orderNumber : orderNumber // ignore: cast_nullable_to_non_nullable +as int?,isNameApproved: freezed == isNameApproved ? _self.isNameApproved : isNameApproved // ignore: cast_nullable_to_non_nullable +as bool?,isHappyPlaceApproved: freezed == isHappyPlaceApproved ? _self.isHappyPlaceApproved : isHappyPlaceApproved // ignore: cast_nullable_to_non_nullable +as bool?,happyPlaceModerationReason: freezed == happyPlaceModerationReason ? _self.happyPlaceModerationReason : happyPlaceModerationReason // ignore: cast_nullable_to_non_nullable +as String?,isImageApproved: freezed == isImageApproved ? _self.isImageApproved : isImageApproved // ignore: cast_nullable_to_non_nullable +as bool?,imageBatchId: freezed == imageBatchId ? _self.imageBatchId : imageBatchId // ignore: cast_nullable_to_non_nullable +as String?,imageUrl: freezed == imageUrl ? _self.imageUrl : imageUrl // ignore: cast_nullable_to_non_nullable +as String?,status: null == status ? _self.status : status // ignore: cast_nullable_to_non_nullable +as LatteOrderStatus,baristaId: freezed == baristaId ? _self.baristaId : baristaId // ignore: cast_nullable_to_non_nullable +as String?,orderSubmittedTime: freezed == orderSubmittedTime ? _self.orderSubmittedTime : orderSubmittedTime // ignore: cast_nullable_to_non_nullable +as DateTime?,completionTime: freezed == completionTime ? _self.completionTime : completionTime // ignore: cast_nullable_to_non_nullable +as DateTime?, + )); +} + +} + + +/// Adds pattern-matching-related methods to [LatteOrderMetadata]. +extension LatteOrderMetadataPatterns on LatteOrderMetadata { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _LatteOrderMetadata value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _LatteOrderMetadata() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _LatteOrderMetadata value) $default,){ +final _that = this; +switch (_that) { +case _LatteOrderMetadata(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _LatteOrderMetadata value)? $default,){ +final _that = this; +switch (_that) { +case _LatteOrderMetadata() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String? id, int? orderNumber, bool? isNameApproved, bool? isHappyPlaceApproved, String? happyPlaceModerationReason, bool? isImageApproved, String? imageBatchId, String? imageUrl, LatteOrderStatus status, String? baristaId, DateTime? orderSubmittedTime, DateTime? completionTime)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _LatteOrderMetadata() when $default != null: +return $default(_that.id,_that.orderNumber,_that.isNameApproved,_that.isHappyPlaceApproved,_that.happyPlaceModerationReason,_that.isImageApproved,_that.imageBatchId,_that.imageUrl,_that.status,_that.baristaId,_that.orderSubmittedTime,_that.completionTime);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String? id, int? orderNumber, bool? isNameApproved, bool? isHappyPlaceApproved, String? happyPlaceModerationReason, bool? isImageApproved, String? imageBatchId, String? imageUrl, LatteOrderStatus status, String? baristaId, DateTime? orderSubmittedTime, DateTime? completionTime) $default,) {final _that = this; +switch (_that) { +case _LatteOrderMetadata(): +return $default(_that.id,_that.orderNumber,_that.isNameApproved,_that.isHappyPlaceApproved,_that.happyPlaceModerationReason,_that.isImageApproved,_that.imageBatchId,_that.imageUrl,_that.status,_that.baristaId,_that.orderSubmittedTime,_that.completionTime);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String? id, int? orderNumber, bool? isNameApproved, bool? isHappyPlaceApproved, String? happyPlaceModerationReason, bool? isImageApproved, String? imageBatchId, String? imageUrl, LatteOrderStatus status, String? baristaId, DateTime? orderSubmittedTime, DateTime? completionTime)? $default,) {final _that = this; +switch (_that) { +case _LatteOrderMetadata() when $default != null: +return $default(_that.id,_that.orderNumber,_that.isNameApproved,_that.isHappyPlaceApproved,_that.happyPlaceModerationReason,_that.isImageApproved,_that.imageBatchId,_that.imageUrl,_that.status,_that.baristaId,_that.orderSubmittedTime,_that.completionTime);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _LatteOrderMetadata extends LatteOrderMetadata { + const _LatteOrderMetadata({this.id, this.orderNumber, this.isNameApproved, this.isHappyPlaceApproved, this.happyPlaceModerationReason, this.isImageApproved, this.imageBatchId, this.imageUrl, this.status = LatteOrderStatus.configuring, this.baristaId, this.orderSubmittedTime, this.completionTime}): super._(); + factory _LatteOrderMetadata.fromJson(Map json) => _$LatteOrderMetadataFromJson(json); + +/// Firestore-generated document Id. Not a user-friendly identifier. +/// This value is guaranteed to be the same as the corresponding +/// [LatteOrder.id]. +@override final String? id; +/// Sequential order number, set by the server and visible in the queue. +@override final int? orderNumber; +/// Gemini sets this to false or true upon moderation. Baristas optionally +/// set this to true when advancing an order's status to +/// [LatteOrderStatus.validated]. Additionally, a value of `true` is +/// required for this user's name to be shown on the big boards. +@override final bool? isNameApproved; +/// Gemini sets this to false or true upon moderation. Image generation is +/// gated by Gemini setting this value to `true`. Later, baristas may +/// override this to `false` when advancing an order's status to +/// [LatteOrderStatus.validated]. If a barista does this, the user will +/// receive a fallback image on their latte. +@override final bool? isHappyPlaceApproved; +/// The reason Gemini rejected the user's happy place prompt. If the image +/// was moderated by the barista, then this value should say +/// "barista_moderation". +@override final String? happyPlaceModerationReason; +/// Set to true once a barista provides final manual approval of the image. +/// This value must be true for an [LatteOrder] to advance to +/// [LatteOrderStatus.inProgress]. +@override final bool? isImageApproved; +/// The active image batch. +@override final String? imageBatchId; +/// Set once the user has committed to an image. +@override final String? imageUrl; +/// The reason an order was sent back from [LatteOrderStatus.submitted] to +/// [LatteOrderStatus.configuring] instead of being accepted and advanced to +/// [LatteOrderStatus.validated]. +// LatteOrderValidationError? validationError, +/// {@macro LatteOrderStatus} +@override@JsonKey() final LatteOrderStatus status; +/// Set to non-null once a barista claims the order. +@override final String? baristaId; +/// Set once the order status reaches [LatteOrderStatus.submitted]. +@override final DateTime? orderSubmittedTime; +/// Set once the order status reaches [LatteOrderStatus.completed]. +@override final DateTime? completionTime; + +/// Create a copy of LatteOrderMetadata +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$LatteOrderMetadataCopyWith<_LatteOrderMetadata> get copyWith => __$LatteOrderMetadataCopyWithImpl<_LatteOrderMetadata>(this, _$identity); + +@override +Map toJson() { + return _$LatteOrderMetadataToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _LatteOrderMetadata&&(identical(other.id, id) || other.id == id)&&(identical(other.orderNumber, orderNumber) || other.orderNumber == orderNumber)&&(identical(other.isNameApproved, isNameApproved) || other.isNameApproved == isNameApproved)&&(identical(other.isHappyPlaceApproved, isHappyPlaceApproved) || other.isHappyPlaceApproved == isHappyPlaceApproved)&&(identical(other.happyPlaceModerationReason, happyPlaceModerationReason) || other.happyPlaceModerationReason == happyPlaceModerationReason)&&(identical(other.isImageApproved, isImageApproved) || other.isImageApproved == isImageApproved)&&(identical(other.imageBatchId, imageBatchId) || other.imageBatchId == imageBatchId)&&(identical(other.imageUrl, imageUrl) || other.imageUrl == imageUrl)&&(identical(other.status, status) || other.status == status)&&(identical(other.baristaId, baristaId) || other.baristaId == baristaId)&&(identical(other.orderSubmittedTime, orderSubmittedTime) || other.orderSubmittedTime == orderSubmittedTime)&&(identical(other.completionTime, completionTime) || other.completionTime == completionTime)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,id,orderNumber,isNameApproved,isHappyPlaceApproved,happyPlaceModerationReason,isImageApproved,imageBatchId,imageUrl,status,baristaId,orderSubmittedTime,completionTime); + +@override +String toString() { + return 'LatteOrderMetadata(id: $id, orderNumber: $orderNumber, isNameApproved: $isNameApproved, isHappyPlaceApproved: $isHappyPlaceApproved, happyPlaceModerationReason: $happyPlaceModerationReason, isImageApproved: $isImageApproved, imageBatchId: $imageBatchId, imageUrl: $imageUrl, status: $status, baristaId: $baristaId, orderSubmittedTime: $orderSubmittedTime, completionTime: $completionTime)'; +} + + +} + +/// @nodoc +abstract mixin class _$LatteOrderMetadataCopyWith<$Res> implements $LatteOrderMetadataCopyWith<$Res> { + factory _$LatteOrderMetadataCopyWith(_LatteOrderMetadata value, $Res Function(_LatteOrderMetadata) _then) = __$LatteOrderMetadataCopyWithImpl; +@override @useResult +$Res call({ + String? id, int? orderNumber, bool? isNameApproved, bool? isHappyPlaceApproved, String? happyPlaceModerationReason, bool? isImageApproved, String? imageBatchId, String? imageUrl, LatteOrderStatus status, String? baristaId, DateTime? orderSubmittedTime, DateTime? completionTime +}); + + + + +} +/// @nodoc +class __$LatteOrderMetadataCopyWithImpl<$Res> + implements _$LatteOrderMetadataCopyWith<$Res> { + __$LatteOrderMetadataCopyWithImpl(this._self, this._then); + + final _LatteOrderMetadata _self; + final $Res Function(_LatteOrderMetadata) _then; + +/// Create a copy of LatteOrderMetadata +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? id = freezed,Object? orderNumber = freezed,Object? isNameApproved = freezed,Object? isHappyPlaceApproved = freezed,Object? happyPlaceModerationReason = freezed,Object? isImageApproved = freezed,Object? imageBatchId = freezed,Object? imageUrl = freezed,Object? status = null,Object? baristaId = freezed,Object? orderSubmittedTime = freezed,Object? completionTime = freezed,}) { + return _then(_LatteOrderMetadata( +id: freezed == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String?,orderNumber: freezed == orderNumber ? _self.orderNumber : orderNumber // ignore: cast_nullable_to_non_nullable +as int?,isNameApproved: freezed == isNameApproved ? _self.isNameApproved : isNameApproved // ignore: cast_nullable_to_non_nullable +as bool?,isHappyPlaceApproved: freezed == isHappyPlaceApproved ? _self.isHappyPlaceApproved : isHappyPlaceApproved // ignore: cast_nullable_to_non_nullable +as bool?,happyPlaceModerationReason: freezed == happyPlaceModerationReason ? _self.happyPlaceModerationReason : happyPlaceModerationReason // ignore: cast_nullable_to_non_nullable +as String?,isImageApproved: freezed == isImageApproved ? _self.isImageApproved : isImageApproved // ignore: cast_nullable_to_non_nullable +as bool?,imageBatchId: freezed == imageBatchId ? _self.imageBatchId : imageBatchId // ignore: cast_nullable_to_non_nullable +as String?,imageUrl: freezed == imageUrl ? _self.imageUrl : imageUrl // ignore: cast_nullable_to_non_nullable +as String?,status: null == status ? _self.status : status // ignore: cast_nullable_to_non_nullable +as LatteOrderStatus,baristaId: freezed == baristaId ? _self.baristaId : baristaId // ignore: cast_nullable_to_non_nullable +as String?,orderSubmittedTime: freezed == orderSubmittedTime ? _self.orderSubmittedTime : orderSubmittedTime // ignore: cast_nullable_to_non_nullable +as DateTime?,completionTime: freezed == completionTime ? _self.completionTime : completionTime // ignore: cast_nullable_to_non_nullable +as DateTime?, + )); +} + + +} + + +/// @nodoc +mixin _$Latte { + + LatteOrder get order; LatteOrderMetadata get metadata; +/// Create a copy of Latte +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$LatteCopyWith get copyWith => _$LatteCopyWithImpl(this as Latte, _$identity); + + /// Serializes this Latte to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is Latte&&(identical(other.order, order) || other.order == order)&&(identical(other.metadata, metadata) || other.metadata == metadata)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,order,metadata); + +@override +String toString() { + return 'Latte(order: $order, metadata: $metadata)'; +} + + +} + +/// @nodoc +abstract mixin class $LatteCopyWith<$Res> { + factory $LatteCopyWith(Latte value, $Res Function(Latte) _then) = _$LatteCopyWithImpl; +@useResult +$Res call({ + LatteOrder order, LatteOrderMetadata metadata +}); + + +$LatteOrderCopyWith<$Res> get order;$LatteOrderMetadataCopyWith<$Res> get metadata; + +} +/// @nodoc +class _$LatteCopyWithImpl<$Res> + implements $LatteCopyWith<$Res> { + _$LatteCopyWithImpl(this._self, this._then); + + final Latte _self; + final $Res Function(Latte) _then; + +/// Create a copy of Latte +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? order = null,Object? metadata = null,}) { + return _then(_self.copyWith( +order: null == order ? _self.order : order // ignore: cast_nullable_to_non_nullable +as LatteOrder,metadata: null == metadata ? _self.metadata : metadata // ignore: cast_nullable_to_non_nullable +as LatteOrderMetadata, + )); +} +/// Create a copy of Latte +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$LatteOrderCopyWith<$Res> get order { + + return $LatteOrderCopyWith<$Res>(_self.order, (value) { + return _then(_self.copyWith(order: value)); + }); +}/// Create a copy of Latte +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$LatteOrderMetadataCopyWith<$Res> get metadata { + + return $LatteOrderMetadataCopyWith<$Res>(_self.metadata, (value) { + return _then(_self.copyWith(metadata: value)); + }); +} +} + + +/// Adds pattern-matching-related methods to [Latte]. +extension LattePatterns on Latte { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _Latte value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _Latte() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _Latte value) $default,){ +final _that = this; +switch (_that) { +case _Latte(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _Latte value)? $default,){ +final _that = this; +switch (_that) { +case _Latte() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( LatteOrder order, LatteOrderMetadata metadata)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _Latte() when $default != null: +return $default(_that.order,_that.metadata);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( LatteOrder order, LatteOrderMetadata metadata) $default,) {final _that = this; +switch (_that) { +case _Latte(): +return $default(_that.order,_that.metadata);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( LatteOrder order, LatteOrderMetadata metadata)? $default,) {final _that = this; +switch (_that) { +case _Latte() when $default != null: +return $default(_that.order,_that.metadata);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _Latte extends Latte { + const _Latte({required this.order, required this.metadata}): super._(); + factory _Latte.fromJson(Map json) => _$LatteFromJson(json); + +@override final LatteOrder order; +@override final LatteOrderMetadata metadata; + +/// Create a copy of Latte +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$LatteCopyWith<_Latte> get copyWith => __$LatteCopyWithImpl<_Latte>(this, _$identity); + +@override +Map toJson() { + return _$LatteToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _Latte&&(identical(other.order, order) || other.order == order)&&(identical(other.metadata, metadata) || other.metadata == metadata)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,order,metadata); + +@override +String toString() { + return 'Latte(order: $order, metadata: $metadata)'; +} + + +} + +/// @nodoc +abstract mixin class _$LatteCopyWith<$Res> implements $LatteCopyWith<$Res> { + factory _$LatteCopyWith(_Latte value, $Res Function(_Latte) _then) = __$LatteCopyWithImpl; +@override @useResult +$Res call({ + LatteOrder order, LatteOrderMetadata metadata +}); + + +@override $LatteOrderCopyWith<$Res> get order;@override $LatteOrderMetadataCopyWith<$Res> get metadata; + +} +/// @nodoc +class __$LatteCopyWithImpl<$Res> + implements _$LatteCopyWith<$Res> { + __$LatteCopyWithImpl(this._self, this._then); + + final _Latte _self; + final $Res Function(_Latte) _then; + +/// Create a copy of Latte +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? order = null,Object? metadata = null,}) { + return _then(_Latte( +order: null == order ? _self.order : order // ignore: cast_nullable_to_non_nullable +as LatteOrder,metadata: null == metadata ? _self.metadata : metadata // ignore: cast_nullable_to_non_nullable +as LatteOrderMetadata, + )); +} + +/// Create a copy of Latte +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$LatteOrderCopyWith<$Res> get order { + + return $LatteOrderCopyWith<$Res>(_self.order, (value) { + return _then(_self.copyWith(order: value)); + }); +}/// Create a copy of Latte +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$LatteOrderMetadataCopyWith<$Res> get metadata { + + return $LatteOrderMetadataCopyWith<$Res>(_self.metadata, (value) { + return _then(_self.copyWith(metadata: value)); + }); +} +} + +// dart format on diff --git a/genlatte/genlatte-data/lib/src/models/latte_order.g.dart b/genlatte/genlatte-data/lib/src/models/latte_order.g.dart new file mode 100644 index 0000000..15a2b14 --- /dev/null +++ b/genlatte/genlatte-data/lib/src/models/latte_order.g.dart @@ -0,0 +1,87 @@ +// Copyright 2014 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. + +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'latte_order.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_LatteOrder _$LatteOrderFromJson(Map json) => _LatteOrder( + id: json['id'] as String?, + name: json['name'] as String?, + milk: json['milk'] as String?, + sweetener: json['sweetener'] as String?, + happyPlace: json['happyPlace'] as String?, +); + +Map _$LatteOrderToJson(_LatteOrder instance) => + { + 'id': instance.id, + 'name': instance.name, + 'milk': instance.milk, + 'sweetener': instance.sweetener, + 'happyPlace': instance.happyPlace, + }; + +_LatteOrderMetadata _$LatteOrderMetadataFromJson(Map json) => + _LatteOrderMetadata( + id: json['id'] as String?, + orderNumber: (json['orderNumber'] as num?)?.toInt(), + isNameApproved: json['isNameApproved'] as bool?, + isHappyPlaceApproved: json['isHappyPlaceApproved'] as bool?, + happyPlaceModerationReason: json['happyPlaceModerationReason'] as String?, + isImageApproved: json['isImageApproved'] as bool?, + imageBatchId: json['imageBatchId'] as String?, + imageUrl: json['imageUrl'] as String?, + status: + $enumDecodeNullable(_$LatteOrderStatusEnumMap, json['status']) ?? + LatteOrderStatus.configuring, + baristaId: json['baristaId'] as String?, + orderSubmittedTime: json['orderSubmittedTime'] == null + ? null + : DateTime.parse(json['orderSubmittedTime'] as String), + completionTime: json['completionTime'] == null + ? null + : DateTime.parse(json['completionTime'] as String), + ); + +Map _$LatteOrderMetadataToJson(_LatteOrderMetadata instance) => + { + 'id': instance.id, + 'orderNumber': instance.orderNumber, + 'isNameApproved': instance.isNameApproved, + 'isHappyPlaceApproved': instance.isHappyPlaceApproved, + 'happyPlaceModerationReason': instance.happyPlaceModerationReason, + 'isImageApproved': instance.isImageApproved, + 'imageBatchId': instance.imageBatchId, + 'imageUrl': instance.imageUrl, + 'status': _$LatteOrderStatusEnumMap[instance.status]!, + 'baristaId': instance.baristaId, + 'orderSubmittedTime': instance.orderSubmittedTime?.toIso8601String(), + 'completionTime': instance.completionTime?.toIso8601String(), + }; + +const _$LatteOrderStatusEnumMap = { + LatteOrderStatus.configuring: 'configuring', + LatteOrderStatus.submitted: 'submitted', + LatteOrderStatus.validated: 'validated', + LatteOrderStatus.inProgress: 'inProgress', + LatteOrderStatus.completed: 'completed', + LatteOrderStatus.archived: 'archived', +}; + +_Latte _$LatteFromJson(Map json) => _Latte( + order: LatteOrder.fromJson(json['order'] as Map), + metadata: LatteOrderMetadata.fromJson( + json['metadata'] as Map, + ), +); + +Map _$LatteToJson(_Latte instance) => { + 'order': instance.order, + 'metadata': instance.metadata, +}; diff --git a/genlatte/genlatte-data/lib/src/models/machine.dart b/genlatte/genlatte-data/lib/src/models/machine.dart new file mode 100644 index 0000000..b8e24eb --- /dev/null +++ b/genlatte/genlatte-data/lib/src/models/machine.dart @@ -0,0 +1,49 @@ +// Copyright 2014 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:data_layer/data_layer.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'machine.freezed.dart'; +part 'machine.g.dart'; + +/// An available printer. +@freezed +abstract class Machine with _$Machine { + /// Instantiates a new [Machine]. + const factory Machine({ + required String id, + required String name, + @Default(true) bool isActive, + @Default(true) bool isBlackAndWhite, + }) = _Machine; + const Machine._(); + + /// Json factory for [Machine]. + factory Machine.fromJson(Map json) => + _$MachineFromJson(json); + + /// Data layer bindings for [Machine]. + static Bindings bindings = Bindings( + fromJson: Machine.fromJson, + toJson: (obj) => obj.toJson(), + getId: (item) => item.id, + getDetailUrl: (id) => ApiUrl(path: 'machines/$id'), + getListUrl: () => const ApiUrl(path: 'machines'), + ); +} + +/// Extension for [Machine] lists. +extension MachineList on List { + /// Converts the list of orders into a map keyed by order id. + Map toIdsMap() { + return fold>( + {}, + (map, machine) { + map[machine.id] = machine; + return map; + }, + ); + } +} diff --git a/genlatte/genlatte-data/lib/src/models/machine.freezed.dart b/genlatte/genlatte-data/lib/src/models/machine.freezed.dart new file mode 100644 index 0000000..ed26b10 --- /dev/null +++ b/genlatte/genlatte-data/lib/src/models/machine.freezed.dart @@ -0,0 +1,290 @@ +// Copyright 2014 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. + +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'machine.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; + +/// @nodoc +mixin _$Machine { + + String get id; String get name; bool get isActive; bool get isBlackAndWhite; +/// Create a copy of Machine +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$MachineCopyWith get copyWith => _$MachineCopyWithImpl(this as Machine, _$identity); + + /// Serializes this Machine to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is Machine&&(identical(other.id, id) || other.id == id)&&(identical(other.name, name) || other.name == name)&&(identical(other.isActive, isActive) || other.isActive == isActive)&&(identical(other.isBlackAndWhite, isBlackAndWhite) || other.isBlackAndWhite == isBlackAndWhite)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,id,name,isActive,isBlackAndWhite); + +@override +String toString() { + return 'Machine(id: $id, name: $name, isActive: $isActive, isBlackAndWhite: $isBlackAndWhite)'; +} + + +} + +/// @nodoc +abstract mixin class $MachineCopyWith<$Res> { + factory $MachineCopyWith(Machine value, $Res Function(Machine) _then) = _$MachineCopyWithImpl; +@useResult +$Res call({ + String id, String name, bool isActive, bool isBlackAndWhite +}); + + + + +} +/// @nodoc +class _$MachineCopyWithImpl<$Res> + implements $MachineCopyWith<$Res> { + _$MachineCopyWithImpl(this._self, this._then); + + final Machine _self; + final $Res Function(Machine) _then; + +/// Create a copy of Machine +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? name = null,Object? isActive = null,Object? isBlackAndWhite = null,}) { + return _then(_self.copyWith( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable +as String,isActive: null == isActive ? _self.isActive : isActive // ignore: cast_nullable_to_non_nullable +as bool,isBlackAndWhite: null == isBlackAndWhite ? _self.isBlackAndWhite : isBlackAndWhite // ignore: cast_nullable_to_non_nullable +as bool, + )); +} + +} + + +/// Adds pattern-matching-related methods to [Machine]. +extension MachinePatterns on Machine { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _Machine value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _Machine() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _Machine value) $default,){ +final _that = this; +switch (_that) { +case _Machine(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _Machine value)? $default,){ +final _that = this; +switch (_that) { +case _Machine() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String id, String name, bool isActive, bool isBlackAndWhite)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _Machine() when $default != null: +return $default(_that.id,_that.name,_that.isActive,_that.isBlackAndWhite);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String id, String name, bool isActive, bool isBlackAndWhite) $default,) {final _that = this; +switch (_that) { +case _Machine(): +return $default(_that.id,_that.name,_that.isActive,_that.isBlackAndWhite);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String id, String name, bool isActive, bool isBlackAndWhite)? $default,) {final _that = this; +switch (_that) { +case _Machine() when $default != null: +return $default(_that.id,_that.name,_that.isActive,_that.isBlackAndWhite);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _Machine extends Machine { + const _Machine({required this.id, required this.name, this.isActive = true, this.isBlackAndWhite = true}): super._(); + factory _Machine.fromJson(Map json) => _$MachineFromJson(json); + +@override final String id; +@override final String name; +@override@JsonKey() final bool isActive; +@override@JsonKey() final bool isBlackAndWhite; + +/// Create a copy of Machine +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$MachineCopyWith<_Machine> get copyWith => __$MachineCopyWithImpl<_Machine>(this, _$identity); + +@override +Map toJson() { + return _$MachineToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _Machine&&(identical(other.id, id) || other.id == id)&&(identical(other.name, name) || other.name == name)&&(identical(other.isActive, isActive) || other.isActive == isActive)&&(identical(other.isBlackAndWhite, isBlackAndWhite) || other.isBlackAndWhite == isBlackAndWhite)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,id,name,isActive,isBlackAndWhite); + +@override +String toString() { + return 'Machine(id: $id, name: $name, isActive: $isActive, isBlackAndWhite: $isBlackAndWhite)'; +} + + +} + +/// @nodoc +abstract mixin class _$MachineCopyWith<$Res> implements $MachineCopyWith<$Res> { + factory _$MachineCopyWith(_Machine value, $Res Function(_Machine) _then) = __$MachineCopyWithImpl; +@override @useResult +$Res call({ + String id, String name, bool isActive, bool isBlackAndWhite +}); + + + + +} +/// @nodoc +class __$MachineCopyWithImpl<$Res> + implements _$MachineCopyWith<$Res> { + __$MachineCopyWithImpl(this._self, this._then); + + final _Machine _self; + final $Res Function(_Machine) _then; + +/// Create a copy of Machine +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? name = null,Object? isActive = null,Object? isBlackAndWhite = null,}) { + return _then(_Machine( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable +as String,isActive: null == isActive ? _self.isActive : isActive // ignore: cast_nullable_to_non_nullable +as bool,isBlackAndWhite: null == isBlackAndWhite ? _self.isBlackAndWhite : isBlackAndWhite // ignore: cast_nullable_to_non_nullable +as bool, + )); +} + + +} + +// dart format on diff --git a/genlatte/genlatte-data/lib/src/models/machine.g.dart b/genlatte/genlatte-data/lib/src/models/machine.g.dart new file mode 100644 index 0000000..79b72e4 --- /dev/null +++ b/genlatte/genlatte-data/lib/src/models/machine.g.dart @@ -0,0 +1,25 @@ +// Copyright 2014 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. + +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'machine.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_Machine _$MachineFromJson(Map json) => _Machine( + id: json['id'] as String, + name: json['name'] as String, + isActive: json['isActive'] as bool? ?? true, + isBlackAndWhite: json['isBlackAndWhite'] as bool? ?? true, +); + +Map _$MachineToJson(_Machine instance) => { + 'id': instance.id, + 'name': instance.name, + 'isActive': instance.isActive, + 'isBlackAndWhite': instance.isBlackAndWhite, +}; diff --git a/genlatte/genlatte-data/lib/src/models/models.dart b/genlatte/genlatte-data/lib/src/models/models.dart new file mode 100644 index 0000000..6884cf7 --- /dev/null +++ b/genlatte/genlatte-data/lib/src/models/models.dart @@ -0,0 +1,10 @@ +// Copyright 2014 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 'barista.dart'; +export 'latte_image.dart'; +export 'latte_options.dart'; +export 'latte_order.dart'; +export 'machine.dart'; +export 'question.dart'; diff --git a/genlatte/genlatte-data/lib/src/models/question.dart b/genlatte/genlatte-data/lib/src/models/question.dart new file mode 100644 index 0000000..4fe030e --- /dev/null +++ b/genlatte/genlatte-data/lib/src/models/question.dart @@ -0,0 +1,112 @@ +// Copyright 2014 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:freezed_annotation/freezed_annotation.dart'; + +part 'question.freezed.dart'; +part 'question.g.dart'; + +/// Question which informs one detail of a coffee or its latte art. +@Freezed(unionKey: 'type') +sealed class Question with _$Question { + /// Simple text-based input, used to ask the user their name or for their + /// happy place. + @FreezedUnionValue('textQuestion') + const factory Question({ + required String id, + @JsonKey(name: 'question') // + required String body, + @Default('Enter your answer') String helpText, + String? answer, + }) = TextQuestion; + + /// Standard multiple choice question, which are provided via + /// [acceptableAnswers]. + @FreezedUnionValue('multipleChoiceQuestion') + const factory Question.multipleChoice({ + required String id, + + @JsonKey(name: 'question') // + required String body, + + required List acceptableAnswers, + String? selectedValue, + }) = MultipleChoiceQuestion; + + /// Simple end-to-end slider, with bookends like "Light" and "Dark". + @FreezedUnionValue('zeroToOneQuestion') + const factory Question.zeroToOne({ + required String id, + @JsonKey(name: 'question') // + required String body, + required String minValueLabel, + required String maxValueLabel, + + /// A value of 0 to 1, representing the value's place between the two + /// labels. + double? selectedValue, + }) = ZeroToOneQuestion; + + /// Slider to shift a value relative to the current perceived value in the + /// image, with values like "Lighter" and "Darker", and which will show + /// "No change" above the midpart of the slider. + @FreezedUnionValue('negativeOneToOneQuestion') + const factory Question.negativeOneToOne({ + required String id, + @JsonKey(name: 'question') // + required String body, + required String minValueLabel, + required String maxValueLabel, + + /// A value of -1 to 1, representing the value's shift relative to the + /// user's perceived value in the original image. + double? selectedValue, + }) = NegativeOneToOneQuestion; + + const Question._(); + + factory Question.fromJson(Map json) => + _$QuestionFromJson(json); + + /// Returns a copy of this question with the given [answer] set. + Question copyWithAnswer(Object? answer) => switch (this) { + MultipleChoiceQuestion() => (this as MultipleChoiceQuestion).copyWith( + selectedValue: answer as String?, + ), + ZeroToOneQuestion() => (this as ZeroToOneQuestion).copyWith( + selectedValue: answer as double?, + ), + NegativeOneToOneQuestion() => (this as NegativeOneToOneQuestion).copyWith( + selectedValue: answer as double?, + ), + TextQuestion() => (this as TextQuestion).copyWith( + answer: answer as String?, + ), + }; + + /// The answer to this question, if it has one. + Object? get answer => switch (this) { + MultipleChoiceQuestion(:final selectedValue) => selectedValue, + ZeroToOneQuestion(:final selectedValue) => selectedValue, + NegativeOneToOneQuestion(:final selectedValue) => selectedValue, + TextQuestion(:final answer) => answer, + }; + + /// True if this question's answer is non-null. + bool get isAnswered => answer != null; +} + +/// {@template QuestionConverter} +/// Json converter for [Question]. +/// {@endtemplate} +class QuestionConverter extends JsonConverter> { + /// {@macro QuestionConverter} + const QuestionConverter(); + + @override + Question fromJson(Map json) => Question.fromJson(json); + + @override + Map toJson(Question object) => object.toJson(); +} diff --git a/genlatte/genlatte-data/lib/src/models/question.freezed.dart b/genlatte/genlatte-data/lib/src/models/question.freezed.dart new file mode 100644 index 0000000..74fc75f --- /dev/null +++ b/genlatte/genlatte-data/lib/src/models/question.freezed.dart @@ -0,0 +1,586 @@ +// Copyright 2014 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. + +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'question.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; +Question _$QuestionFromJson( + Map json +) { + switch (json['type']) { + case 'textQuestion': + return TextQuestion.fromJson( + json + ); + case 'multipleChoiceQuestion': + return MultipleChoiceQuestion.fromJson( + json + ); + case 'zeroToOneQuestion': + return ZeroToOneQuestion.fromJson( + json + ); + case 'negativeOneToOneQuestion': + return NegativeOneToOneQuestion.fromJson( + json + ); + + default: + throw CheckedFromJsonException( + json, + 'type', + 'Question', + 'Invalid union type "${json['type']}"!' +); + } + +} + +/// @nodoc +mixin _$Question { + + String get id;@JsonKey(name: 'question') String get body; +/// Create a copy of Question +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$QuestionCopyWith get copyWith => _$QuestionCopyWithImpl(this as Question, _$identity); + + /// Serializes this Question to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is Question&&(identical(other.id, id) || other.id == id)&&(identical(other.body, body) || other.body == body)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,id,body); + +@override +String toString() { + return 'Question(id: $id, body: $body)'; +} + + +} + +/// @nodoc +abstract mixin class $QuestionCopyWith<$Res> { + factory $QuestionCopyWith(Question value, $Res Function(Question) _then) = _$QuestionCopyWithImpl; +@useResult +$Res call({ + String id,@JsonKey(name: 'question') String body +}); + + + + +} +/// @nodoc +class _$QuestionCopyWithImpl<$Res> + implements $QuestionCopyWith<$Res> { + _$QuestionCopyWithImpl(this._self, this._then); + + final Question _self; + final $Res Function(Question) _then; + +/// Create a copy of Question +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? body = null,}) { + return _then(_self.copyWith( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String,body: null == body ? _self.body : body // ignore: cast_nullable_to_non_nullable +as String, + )); +} + +} + + +/// Adds pattern-matching-related methods to [Question]. +extension QuestionPatterns on Question { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( TextQuestion value)? $default,{TResult Function( MultipleChoiceQuestion value)? multipleChoice,TResult Function( ZeroToOneQuestion value)? zeroToOne,TResult Function( NegativeOneToOneQuestion value)? negativeOneToOne,required TResult orElse(),}){ +final _that = this; +switch (_that) { +case TextQuestion() when $default != null: +return $default(_that);case MultipleChoiceQuestion() when multipleChoice != null: +return multipleChoice(_that);case ZeroToOneQuestion() when zeroToOne != null: +return zeroToOne(_that);case NegativeOneToOneQuestion() when negativeOneToOne != null: +return negativeOneToOne(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( TextQuestion value) $default,{required TResult Function( MultipleChoiceQuestion value) multipleChoice,required TResult Function( ZeroToOneQuestion value) zeroToOne,required TResult Function( NegativeOneToOneQuestion value) negativeOneToOne,}){ +final _that = this; +switch (_that) { +case TextQuestion(): +return $default(_that);case MultipleChoiceQuestion(): +return multipleChoice(_that);case ZeroToOneQuestion(): +return zeroToOne(_that);case NegativeOneToOneQuestion(): +return negativeOneToOne(_that);} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( TextQuestion value)? $default,{TResult? Function( MultipleChoiceQuestion value)? multipleChoice,TResult? Function( ZeroToOneQuestion value)? zeroToOne,TResult? Function( NegativeOneToOneQuestion value)? negativeOneToOne,}){ +final _that = this; +switch (_that) { +case TextQuestion() when $default != null: +return $default(_that);case MultipleChoiceQuestion() when multipleChoice != null: +return multipleChoice(_that);case ZeroToOneQuestion() when zeroToOne != null: +return zeroToOne(_that);case NegativeOneToOneQuestion() when negativeOneToOne != null: +return negativeOneToOne(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String id, @JsonKey(name: 'question') String body, String helpText, String? answer)? $default,{TResult Function( String id, @JsonKey(name: 'question') String body, List acceptableAnswers, String? selectedValue)? multipleChoice,TResult Function( String id, @JsonKey(name: 'question') String body, String minValueLabel, String maxValueLabel, double? selectedValue)? zeroToOne,TResult Function( String id, @JsonKey(name: 'question') String body, String minValueLabel, String maxValueLabel, double? selectedValue)? negativeOneToOne,required TResult orElse(),}) {final _that = this; +switch (_that) { +case TextQuestion() when $default != null: +return $default(_that.id,_that.body,_that.helpText,_that.answer);case MultipleChoiceQuestion() when multipleChoice != null: +return multipleChoice(_that.id,_that.body,_that.acceptableAnswers,_that.selectedValue);case ZeroToOneQuestion() when zeroToOne != null: +return zeroToOne(_that.id,_that.body,_that.minValueLabel,_that.maxValueLabel,_that.selectedValue);case NegativeOneToOneQuestion() when negativeOneToOne != null: +return negativeOneToOne(_that.id,_that.body,_that.minValueLabel,_that.maxValueLabel,_that.selectedValue);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String id, @JsonKey(name: 'question') String body, String helpText, String? answer) $default,{required TResult Function( String id, @JsonKey(name: 'question') String body, List acceptableAnswers, String? selectedValue) multipleChoice,required TResult Function( String id, @JsonKey(name: 'question') String body, String minValueLabel, String maxValueLabel, double? selectedValue) zeroToOne,required TResult Function( String id, @JsonKey(name: 'question') String body, String minValueLabel, String maxValueLabel, double? selectedValue) negativeOneToOne,}) {final _that = this; +switch (_that) { +case TextQuestion(): +return $default(_that.id,_that.body,_that.helpText,_that.answer);case MultipleChoiceQuestion(): +return multipleChoice(_that.id,_that.body,_that.acceptableAnswers,_that.selectedValue);case ZeroToOneQuestion(): +return zeroToOne(_that.id,_that.body,_that.minValueLabel,_that.maxValueLabel,_that.selectedValue);case NegativeOneToOneQuestion(): +return negativeOneToOne(_that.id,_that.body,_that.minValueLabel,_that.maxValueLabel,_that.selectedValue);} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String id, @JsonKey(name: 'question') String body, String helpText, String? answer)? $default,{TResult? Function( String id, @JsonKey(name: 'question') String body, List acceptableAnswers, String? selectedValue)? multipleChoice,TResult? Function( String id, @JsonKey(name: 'question') String body, String minValueLabel, String maxValueLabel, double? selectedValue)? zeroToOne,TResult? Function( String id, @JsonKey(name: 'question') String body, String minValueLabel, String maxValueLabel, double? selectedValue)? negativeOneToOne,}) {final _that = this; +switch (_that) { +case TextQuestion() when $default != null: +return $default(_that.id,_that.body,_that.helpText,_that.answer);case MultipleChoiceQuestion() when multipleChoice != null: +return multipleChoice(_that.id,_that.body,_that.acceptableAnswers,_that.selectedValue);case ZeroToOneQuestion() when zeroToOne != null: +return zeroToOne(_that.id,_that.body,_that.minValueLabel,_that.maxValueLabel,_that.selectedValue);case NegativeOneToOneQuestion() when negativeOneToOne != null: +return negativeOneToOne(_that.id,_that.body,_that.minValueLabel,_that.maxValueLabel,_that.selectedValue);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class TextQuestion extends Question { + const TextQuestion({required this.id, @JsonKey(name: 'question') required this.body, this.helpText = 'Enter your answer', this.answer, final String? $type}): $type = $type ?? 'textQuestion',super._(); + factory TextQuestion.fromJson(Map json) => _$TextQuestionFromJson(json); + +@override final String id; +@override@JsonKey(name: 'question') final String body; +@JsonKey() final String helpText; + final String? answer; + +@JsonKey(name: 'type') +final String $type; + + +/// Create a copy of Question +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$TextQuestionCopyWith get copyWith => _$TextQuestionCopyWithImpl(this, _$identity); + +@override +Map toJson() { + return _$TextQuestionToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is TextQuestion&&(identical(other.id, id) || other.id == id)&&(identical(other.body, body) || other.body == body)&&(identical(other.helpText, helpText) || other.helpText == helpText)&&(identical(other.answer, answer) || other.answer == answer)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,id,body,helpText,answer); + +@override +String toString() { + return 'Question(id: $id, body: $body, helpText: $helpText, answer: $answer)'; +} + + +} + +/// @nodoc +abstract mixin class $TextQuestionCopyWith<$Res> implements $QuestionCopyWith<$Res> { + factory $TextQuestionCopyWith(TextQuestion value, $Res Function(TextQuestion) _then) = _$TextQuestionCopyWithImpl; +@override @useResult +$Res call({ + String id,@JsonKey(name: 'question') String body, String helpText, String? answer +}); + + + + +} +/// @nodoc +class _$TextQuestionCopyWithImpl<$Res> + implements $TextQuestionCopyWith<$Res> { + _$TextQuestionCopyWithImpl(this._self, this._then); + + final TextQuestion _self; + final $Res Function(TextQuestion) _then; + +/// Create a copy of Question +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? body = null,Object? helpText = null,Object? answer = freezed,}) { + return _then(TextQuestion( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String,body: null == body ? _self.body : body // ignore: cast_nullable_to_non_nullable +as String,helpText: null == helpText ? _self.helpText : helpText // ignore: cast_nullable_to_non_nullable +as String,answer: freezed == answer ? _self.answer : answer // ignore: cast_nullable_to_non_nullable +as String?, + )); +} + + +} + +/// @nodoc +@JsonSerializable() + +class MultipleChoiceQuestion extends Question { + const MultipleChoiceQuestion({required this.id, @JsonKey(name: 'question') required this.body, required final List acceptableAnswers, this.selectedValue, final String? $type}): _acceptableAnswers = acceptableAnswers,$type = $type ?? 'multipleChoiceQuestion',super._(); + factory MultipleChoiceQuestion.fromJson(Map json) => _$MultipleChoiceQuestionFromJson(json); + +@override final String id; +@override@JsonKey(name: 'question') final String body; + final List _acceptableAnswers; + List get acceptableAnswers { + if (_acceptableAnswers is EqualUnmodifiableListView) return _acceptableAnswers; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_acceptableAnswers); +} + + final String? selectedValue; + +@JsonKey(name: 'type') +final String $type; + + +/// Create a copy of Question +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$MultipleChoiceQuestionCopyWith get copyWith => _$MultipleChoiceQuestionCopyWithImpl(this, _$identity); + +@override +Map toJson() { + return _$MultipleChoiceQuestionToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is MultipleChoiceQuestion&&(identical(other.id, id) || other.id == id)&&(identical(other.body, body) || other.body == body)&&const DeepCollectionEquality().equals(other._acceptableAnswers, _acceptableAnswers)&&(identical(other.selectedValue, selectedValue) || other.selectedValue == selectedValue)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,id,body,const DeepCollectionEquality().hash(_acceptableAnswers),selectedValue); + +@override +String toString() { + return 'Question.multipleChoice(id: $id, body: $body, acceptableAnswers: $acceptableAnswers, selectedValue: $selectedValue)'; +} + + +} + +/// @nodoc +abstract mixin class $MultipleChoiceQuestionCopyWith<$Res> implements $QuestionCopyWith<$Res> { + factory $MultipleChoiceQuestionCopyWith(MultipleChoiceQuestion value, $Res Function(MultipleChoiceQuestion) _then) = _$MultipleChoiceQuestionCopyWithImpl; +@override @useResult +$Res call({ + String id,@JsonKey(name: 'question') String body, List acceptableAnswers, String? selectedValue +}); + + + + +} +/// @nodoc +class _$MultipleChoiceQuestionCopyWithImpl<$Res> + implements $MultipleChoiceQuestionCopyWith<$Res> { + _$MultipleChoiceQuestionCopyWithImpl(this._self, this._then); + + final MultipleChoiceQuestion _self; + final $Res Function(MultipleChoiceQuestion) _then; + +/// Create a copy of Question +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? body = null,Object? acceptableAnswers = null,Object? selectedValue = freezed,}) { + return _then(MultipleChoiceQuestion( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String,body: null == body ? _self.body : body // ignore: cast_nullable_to_non_nullable +as String,acceptableAnswers: null == acceptableAnswers ? _self._acceptableAnswers : acceptableAnswers // ignore: cast_nullable_to_non_nullable +as List,selectedValue: freezed == selectedValue ? _self.selectedValue : selectedValue // ignore: cast_nullable_to_non_nullable +as String?, + )); +} + + +} + +/// @nodoc +@JsonSerializable() + +class ZeroToOneQuestion extends Question { + const ZeroToOneQuestion({required this.id, @JsonKey(name: 'question') required this.body, required this.minValueLabel, required this.maxValueLabel, this.selectedValue, final String? $type}): $type = $type ?? 'zeroToOneQuestion',super._(); + factory ZeroToOneQuestion.fromJson(Map json) => _$ZeroToOneQuestionFromJson(json); + +@override final String id; +@override@JsonKey(name: 'question') final String body; + final String minValueLabel; + final String maxValueLabel; +/// A value of 0 to 1, representing the value's place between the two +/// labels. + final double? selectedValue; + +@JsonKey(name: 'type') +final String $type; + + +/// Create a copy of Question +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$ZeroToOneQuestionCopyWith get copyWith => _$ZeroToOneQuestionCopyWithImpl(this, _$identity); + +@override +Map toJson() { + return _$ZeroToOneQuestionToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is ZeroToOneQuestion&&(identical(other.id, id) || other.id == id)&&(identical(other.body, body) || other.body == body)&&(identical(other.minValueLabel, minValueLabel) || other.minValueLabel == minValueLabel)&&(identical(other.maxValueLabel, maxValueLabel) || other.maxValueLabel == maxValueLabel)&&(identical(other.selectedValue, selectedValue) || other.selectedValue == selectedValue)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,id,body,minValueLabel,maxValueLabel,selectedValue); + +@override +String toString() { + return 'Question.zeroToOne(id: $id, body: $body, minValueLabel: $minValueLabel, maxValueLabel: $maxValueLabel, selectedValue: $selectedValue)'; +} + + +} + +/// @nodoc +abstract mixin class $ZeroToOneQuestionCopyWith<$Res> implements $QuestionCopyWith<$Res> { + factory $ZeroToOneQuestionCopyWith(ZeroToOneQuestion value, $Res Function(ZeroToOneQuestion) _then) = _$ZeroToOneQuestionCopyWithImpl; +@override @useResult +$Res call({ + String id,@JsonKey(name: 'question') String body, String minValueLabel, String maxValueLabel, double? selectedValue +}); + + + + +} +/// @nodoc +class _$ZeroToOneQuestionCopyWithImpl<$Res> + implements $ZeroToOneQuestionCopyWith<$Res> { + _$ZeroToOneQuestionCopyWithImpl(this._self, this._then); + + final ZeroToOneQuestion _self; + final $Res Function(ZeroToOneQuestion) _then; + +/// Create a copy of Question +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? body = null,Object? minValueLabel = null,Object? maxValueLabel = null,Object? selectedValue = freezed,}) { + return _then(ZeroToOneQuestion( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String,body: null == body ? _self.body : body // ignore: cast_nullable_to_non_nullable +as String,minValueLabel: null == minValueLabel ? _self.minValueLabel : minValueLabel // ignore: cast_nullable_to_non_nullable +as String,maxValueLabel: null == maxValueLabel ? _self.maxValueLabel : maxValueLabel // ignore: cast_nullable_to_non_nullable +as String,selectedValue: freezed == selectedValue ? _self.selectedValue : selectedValue // ignore: cast_nullable_to_non_nullable +as double?, + )); +} + + +} + +/// @nodoc +@JsonSerializable() + +class NegativeOneToOneQuestion extends Question { + const NegativeOneToOneQuestion({required this.id, @JsonKey(name: 'question') required this.body, required this.minValueLabel, required this.maxValueLabel, this.selectedValue, final String? $type}): $type = $type ?? 'negativeOneToOneQuestion',super._(); + factory NegativeOneToOneQuestion.fromJson(Map json) => _$NegativeOneToOneQuestionFromJson(json); + +@override final String id; +@override@JsonKey(name: 'question') final String body; + final String minValueLabel; + final String maxValueLabel; +/// A value of -1 to 1, representing the value's shift relative to the +/// user's perceived value in the original image. + final double? selectedValue; + +@JsonKey(name: 'type') +final String $type; + + +/// Create a copy of Question +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$NegativeOneToOneQuestionCopyWith get copyWith => _$NegativeOneToOneQuestionCopyWithImpl(this, _$identity); + +@override +Map toJson() { + return _$NegativeOneToOneQuestionToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is NegativeOneToOneQuestion&&(identical(other.id, id) || other.id == id)&&(identical(other.body, body) || other.body == body)&&(identical(other.minValueLabel, minValueLabel) || other.minValueLabel == minValueLabel)&&(identical(other.maxValueLabel, maxValueLabel) || other.maxValueLabel == maxValueLabel)&&(identical(other.selectedValue, selectedValue) || other.selectedValue == selectedValue)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,id,body,minValueLabel,maxValueLabel,selectedValue); + +@override +String toString() { + return 'Question.negativeOneToOne(id: $id, body: $body, minValueLabel: $minValueLabel, maxValueLabel: $maxValueLabel, selectedValue: $selectedValue)'; +} + + +} + +/// @nodoc +abstract mixin class $NegativeOneToOneQuestionCopyWith<$Res> implements $QuestionCopyWith<$Res> { + factory $NegativeOneToOneQuestionCopyWith(NegativeOneToOneQuestion value, $Res Function(NegativeOneToOneQuestion) _then) = _$NegativeOneToOneQuestionCopyWithImpl; +@override @useResult +$Res call({ + String id,@JsonKey(name: 'question') String body, String minValueLabel, String maxValueLabel, double? selectedValue +}); + + + + +} +/// @nodoc +class _$NegativeOneToOneQuestionCopyWithImpl<$Res> + implements $NegativeOneToOneQuestionCopyWith<$Res> { + _$NegativeOneToOneQuestionCopyWithImpl(this._self, this._then); + + final NegativeOneToOneQuestion _self; + final $Res Function(NegativeOneToOneQuestion) _then; + +/// Create a copy of Question +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? body = null,Object? minValueLabel = null,Object? maxValueLabel = null,Object? selectedValue = freezed,}) { + return _then(NegativeOneToOneQuestion( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String,body: null == body ? _self.body : body // ignore: cast_nullable_to_non_nullable +as String,minValueLabel: null == minValueLabel ? _self.minValueLabel : minValueLabel // ignore: cast_nullable_to_non_nullable +as String,maxValueLabel: null == maxValueLabel ? _self.maxValueLabel : maxValueLabel // ignore: cast_nullable_to_non_nullable +as String,selectedValue: freezed == selectedValue ? _self.selectedValue : selectedValue // ignore: cast_nullable_to_non_nullable +as double?, + )); +} + + +} + +// dart format on diff --git a/genlatte/genlatte-data/lib/src/models/question.g.dart b/genlatte/genlatte-data/lib/src/models/question.g.dart new file mode 100644 index 0000000..f2ca892 --- /dev/null +++ b/genlatte/genlatte-data/lib/src/models/question.g.dart @@ -0,0 +1,92 @@ +// Copyright 2014 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. + +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'question.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +TextQuestion _$TextQuestionFromJson(Map json) => TextQuestion( + id: json['id'] as String, + body: json['question'] as String, + helpText: json['helpText'] as String? ?? 'Enter your answer', + answer: json['answer'] as String?, + $type: json['type'] as String?, +); + +Map _$TextQuestionToJson(TextQuestion instance) => + { + 'id': instance.id, + 'question': instance.body, + 'helpText': instance.helpText, + 'answer': instance.answer, + 'type': instance.$type, + }; + +MultipleChoiceQuestion _$MultipleChoiceQuestionFromJson( + Map json, +) => MultipleChoiceQuestion( + id: json['id'] as String, + body: json['question'] as String, + acceptableAnswers: (json['acceptableAnswers'] as List) + .map((e) => e as String) + .toList(), + selectedValue: json['selectedValue'] as String?, + $type: json['type'] as String?, +); + +Map _$MultipleChoiceQuestionToJson( + MultipleChoiceQuestion instance, +) => { + 'id': instance.id, + 'question': instance.body, + 'acceptableAnswers': instance.acceptableAnswers, + 'selectedValue': instance.selectedValue, + 'type': instance.$type, +}; + +ZeroToOneQuestion _$ZeroToOneQuestionFromJson(Map json) => + ZeroToOneQuestion( + id: json['id'] as String, + body: json['question'] as String, + minValueLabel: json['minValueLabel'] as String, + maxValueLabel: json['maxValueLabel'] as String, + selectedValue: (json['selectedValue'] as num?)?.toDouble(), + $type: json['type'] as String?, + ); + +Map _$ZeroToOneQuestionToJson(ZeroToOneQuestion instance) => + { + 'id': instance.id, + 'question': instance.body, + 'minValueLabel': instance.minValueLabel, + 'maxValueLabel': instance.maxValueLabel, + 'selectedValue': instance.selectedValue, + 'type': instance.$type, + }; + +NegativeOneToOneQuestion _$NegativeOneToOneQuestionFromJson( + Map json, +) => NegativeOneToOneQuestion( + id: json['id'] as String, + body: json['question'] as String, + minValueLabel: json['minValueLabel'] as String, + maxValueLabel: json['maxValueLabel'] as String, + selectedValue: (json['selectedValue'] as num?)?.toDouble(), + $type: json['type'] as String?, +); + +Map _$NegativeOneToOneQuestionToJson( + NegativeOneToOneQuestion instance, +) => { + 'id': instance.id, + 'question': instance.body, + 'minValueLabel': instance.minValueLabel, + 'maxValueLabel': instance.maxValueLabel, + 'selectedValue': instance.selectedValue, + 'type': instance.$type, +}; diff --git a/genlatte/genlatte-data/pubspec.yaml b/genlatte/genlatte-data/pubspec.yaml new file mode 100644 index 0000000..c3ce2bf --- /dev/null +++ b/genlatte/genlatte-data/pubspec.yaml @@ -0,0 +1,20 @@ +name: genlatte_data +description: A starting point for Dart libraries or applications. +version: 1.0.0 +# repository: https://github.com/my_org/my_repo +environment: + sdk: ^3.11.0-296.4.beta + +# Add regular dependencies here. +dependencies: + data_layer: 0.0.5 + freezed_annotation: ^3.1.0 + json_annotation: ^4.10.0 + +dev_dependencies: + build_runner: ^2.10.5 + freezed: ^3.2.4 + json_serializable: ^6.12.0 + mocktail: ^1.0.4 + test: ^1.25.6 + very_good_analysis: ^10.0.0 diff --git a/genlatte/genlatte-data/test/CONTEXT.md b/genlatte/genlatte-data/test/CONTEXT.md new file mode 100644 index 0000000..b198d89 --- /dev/null +++ b/genlatte/genlatte-data/test/CONTEXT.md @@ -0,0 +1,4 @@ +# genlatte-data Tests + +**Purpose:** +Unit verification encompassing data models properties and behavior parity. diff --git a/genlatte/genlatte-data/test/models/CONTEXT.md b/genlatte/genlatte-data/test/models/CONTEXT.md new file mode 100644 index 0000000..98a4426 --- /dev/null +++ b/genlatte/genlatte-data/test/models/CONTEXT.md @@ -0,0 +1,4 @@ +# genlatte-data Model Tests + +**Purpose:** +Tests verifying JsonSerialization implementations for nested Freezed records (e.g. `LatteOrder`). diff --git a/genlatte/genlatte-data/test/models/barista_test.dart b/genlatte/genlatte-data/test/models/barista_test.dart new file mode 100644 index 0000000..02695de --- /dev/null +++ b/genlatte/genlatte-data/test/models/barista_test.dart @@ -0,0 +1,42 @@ +// Copyright 2014 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:genlatte_data/models.dart'; +import 'package:test/test.dart'; + +void main() { + group('Barista Serialization', () { + test('JSON serialization is correct for a Barista', () { + final json = { + 'id': 'b123', + 'username': 'jon_doe', + 'persona': 'asianMale', + }; + + final barista = Barista.fromJson(json); + + expect(barista.id, 'b123'); + expect(barista.username, 'jon_doe'); + expect(barista.persona, BaristaPersona.asianMale); + + final serialized = barista.toJson(); + expect(serialized['id'], 'b123'); + expect(serialized['username'], 'jon_doe'); + expect(serialized['persona'], 'asianMale'); + }); + + test('JSON serialization for Barista without id', () { + final json = { + 'username': 'jane_doe', + 'persona': 'caucasianFemale', + }; + + final barista = Barista.fromJson(json); + + expect(barista.id, isNull); + expect(barista.username, 'jane_doe'); + expect(barista.persona, BaristaPersona.caucasianFemale); + }); + }); +} diff --git a/genlatte/genlatte-data/test/models/latte_image_test.dart b/genlatte/genlatte-data/test/models/latte_image_test.dart new file mode 100644 index 0000000..30836c8 --- /dev/null +++ b/genlatte/genlatte-data/test/models/latte_image_test.dart @@ -0,0 +1,228 @@ +// Copyright 2014 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:genlatte_data/models.dart'; +import 'package:test/test.dart'; + +void main() { + group('LatteImageBatchParent Serialization', () { + test('JSON serialization is correct for a LatteImageBatchParent', () { + final json = { + 'id': 'batch1', + 'imageIndex': 'image1', + }; + + final parent = LatteImageBatchParent.fromJson(json); + + expect(parent.id, 'batch1'); + expect(parent.imageIndex, 'image1'); + + final serialized = parent.toJson(); + expect(serialized['id'], 'batch1'); + expect(serialized['imageIndex'], 'image1'); + }); + }); + + group('LatteImage Serialization', () { + test('JSON serialization is correct for a LatteImage', () { + final json = { + 'imageUrl': 'https://example.com/latte.jpg', + 'prompt': 'A beautiful swan in coffee', + 'description': 'A beautiful swan', + 'questions': [ + { + 'type': 'textQuestion', + 'id': 'q1', + 'question': 'What would you name it?', + 'helpText': 'Enter your answer', + }, + ], + }; + + final image = LatteImage.fromJson(json); + + expect(image.imageUrl, 'https://example.com/latte.jpg'); + expect(image.prompt, 'A beautiful swan in coffee'); + expect(image.description, 'A beautiful swan'); + expect(image.questions?.length, 1); + final q = image.questions!.first; + expect(q, isA()); + expect((q as TextQuestion).body, 'What would you name it?'); + + final serialized = image.toJson(); + expect(serialized['imageUrl'], 'https://example.com/latte.jpg'); + expect(serialized['prompt'], 'A beautiful swan in coffee'); + expect( + (serialized['questions'] as List>).first['type'], + 'textQuestion', + ); + }); + }); + + group('LatteImageBatch Serialization', () { + test('JSON serialization is correct for a LatteImageBatch', () { + final json = { + 'id': 'batch001', + 'orderId': 'order001', + 'image0': { + 'imageUrl': 'https://example.com/0.jpg', + 'prompt': 'Prompt 0', + 'description': 'Desc 0', + 'questions': null, + }, + 'image1': { + 'imageUrl': 'https://example.com/1.jpg', + 'prompt': 'Prompt 1', + 'description': 'Desc 1', + 'questions': [], + }, + 'image2': null, + 'image3': null, + 'parent': {'id': 'batch000', 'imageIndex': 'image2'}, + }; + + final batch = LatteImageBatch.fromJson(json); + + expect(batch.id, 'batch001'); + expect(batch.orderId, 'order001'); + expect(batch.image0?.imageUrl, 'https://example.com/0.jpg'); + expect(batch.image1?.prompt, 'Prompt 1'); + expect(batch.image2, isNull); + expect(batch.parent?.imageIndex, 'image2'); + + final serialized = batch.toJson(); + expect(serialized['id'], 'batch001'); + expect(serialized['orderId'], 'order001'); + expect( + (serialized['image0'] as Map)['imageUrl'], + 'https://example.com/0.jpg', + ); + expect(serialized['image2'], isNull); + expect((serialized['parent'] as Map)['imageIndex'], 'image2'); + }); + }); + + group('LatteImage answerQuestion', () { + const defaultQuestion = Question.multipleChoice( + id: 'q1', + body: 'Size?', + acceptableAnswers: ['Small', 'Large'], + ); + const textQuestion = Question( + id: 'q2', + body: 'Name?', + ); + const img = LatteImage( + imageUrl: 'url', + prompt: 'prompt', + description: 'desc', + questions: [defaultQuestion, textQuestion], + ); + + test('updates the correct question with the given answer', () { + final updatedImg = img.answerQuestion(defaultQuestion, 'Large'); + + expect( + (updatedImg.questions![0] as MultipleChoiceQuestion).selectedValue, + 'Large', + ); + expect((updatedImg.questions![1] as TextQuestion).answer, isNull); + }); + + test('throws if questions list is null or empty', () { + const emptyImg = LatteImage( + imageUrl: 'url', + prompt: 'prompt', + description: 'desc', + questions: null, + ); + + expect( + () => emptyImg.answerQuestion(defaultQuestion, 'answer'), + throwsA(isA()), + ); + }); + + test('throws if question is not found in the list', () { + const notFoundQuestion = Question(id: 'q3', body: 'Missing?'); + expect( + () => img.answerQuestion(notFoundQuestion, 'answer'), + throwsException, + ); + }); + }); + + group('LatteImageBatch answerQuestion', () { + const q = Question(id: 'q1', body: 'Test?'); + const imgTemplate = LatteImage( + imageUrl: 'url', + prompt: 'prompt', + description: 'desc', + questions: [q], + ); + const batch = LatteImageBatch( + id: 'batch1', + orderId: 'order1', + image0: imgTemplate, + image1: imgTemplate, + image2: imgTemplate, + image3: imgTemplate, + ); + + test('updates image0 if index is 0', () { + final updatedBatch = batch.answerQuestion(0, q, 'ans0'); + expect( + (updatedBatch.image0!.questions!.first as TextQuestion).answer, + 'ans0', + ); + expect( + (updatedBatch.image1!.questions!.first as TextQuestion).answer, + isNull, + ); + }); + + test('updates image1 if index is 1', () { + final updatedBatch = batch.answerQuestion(1, q, 'ans1'); + expect( + (updatedBatch.image1!.questions!.first as TextQuestion).answer, + 'ans1', + ); + expect( + (updatedBatch.image0!.questions!.first as TextQuestion).answer, + isNull, + ); + }); + + test('updates image2 if index is 2', () { + final updatedBatch = batch.answerQuestion(2, q, 'ans2'); + expect( + (updatedBatch.image2!.questions!.first as TextQuestion).answer, + 'ans2', + ); + expect( + (updatedBatch.image3!.questions!.first as TextQuestion).answer, + isNull, + ); + }); + + test('updates image3 if index is 3', () { + final updatedBatch = batch.answerQuestion(3, q, 'ans3'); + expect( + (updatedBatch.image3!.questions!.first as TextQuestion).answer, + 'ans3', + ); + expect( + (updatedBatch.image2!.questions!.first as TextQuestion).answer, + isNull, + ); + }); + + test('throws RangeError for invalid index', () { + expect( + () => batch.answerQuestion(4, q, 'ans'), + throwsRangeError, + ); + }); + }); +} diff --git a/genlatte/genlatte-data/test/models/latte_options_test.dart b/genlatte/genlatte-data/test/models/latte_options_test.dart new file mode 100644 index 0000000..fbde428 --- /dev/null +++ b/genlatte/genlatte-data/test/models/latte_options_test.dart @@ -0,0 +1,42 @@ +// Copyright 2014 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:data_layer/data_layer.dart'; +import 'package:genlatte_data/models.dart'; +import 'package:test/test.dart'; + +void main() { + group('LatteOptions Serialization', () { + test('JSON serialization is correct for a LatteOptions', () { + final json = { + 'id': 'milk', + 'values': [ + {'name': 'Oat'}, + {'name': 'Almond'}, + {'name': 'Whole'}, + ], + }; + + final options = LatteOptions.fromJson(json); + + expect(options.id, 'milk'); + expect(options.values, [ + const LatteOption(name: 'Oat'), + const LatteOption(name: 'Almond'), + const LatteOption(name: 'Whole'), + ]); + + final serialized = options.toJson(); + expect(serialized['id'], 'milk'); + expect( + serialized['values'], + [ + {'name': 'Oat', 'isAvailable': true}, + {'name': 'Almond', 'isAvailable': true}, + {'name': 'Whole', 'isAvailable': true}, + ], + ); + }); + }); +} diff --git a/genlatte/genlatte-data/test/models/latte_order_test.dart b/genlatte/genlatte-data/test/models/latte_order_test.dart new file mode 100644 index 0000000..4108ec2 --- /dev/null +++ b/genlatte/genlatte-data/test/models/latte_order_test.dart @@ -0,0 +1,88 @@ +// Copyright 2014 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:genlatte_data/models.dart'; +import 'package:test/test.dart'; + +void main() { + group('LatteOrder Serialization', () { + test('JSON serialization is correct for a fully populated order', () { + final json = { + 'id': 'order-123', + 'name': 'Alice', + 'milk': 'Oat', + 'sweetener': 'Vanilla', + 'happyPlace': 'A cozy cabin', + }; + + final order = LatteOrder.fromJson(json); + + expect(order.id, 'order-123'); + expect(order.name, 'Alice'); + expect(order.milk, 'Oat'); + expect(order.sweetener, 'Vanilla'); + expect(order.happyPlace, 'A cozy cabin'); + + expect(order.toJson(), json); + }); + + test('JSON serialization is correct for an empty order', () { + final json = { + 'id': null, + 'name': null, + 'milk': null, + 'sweetener': null, + 'happyPlace': null, + }; + + final order = LatteOrder.fromJson(json); + + expect(order.id, isNull); + expect(order.name, isNull); + + // Nulls are generally excluded or included depending on json_serializable + // config. But we can verify toJson includes what's expected. We should + // match the actual object property Let's just create an empty order and + // see its toJson. + const emptyOrder = LatteOrder(); + + // We expect unpopulated fields to either serialize as null or be omitted. + // Since freezed creates `id?: String` etc, the json will likely have the + // keys if includeIfNull: true We will assert on the required structure. + final serialized = emptyOrder.toJson(); + // Only verifying fields that actually exist or not throwing error + expect(serialized.containsKey('id'), isTrue); + }); + }); + + group('LatteOrderMetadata Serialization', () { + test('JSON serialization is correct for a fully populated metadata', () { + final json = { + 'id': 'meta-123', + 'orderNumber': 42, + 'isNameApproved': true, + 'isHappyPlaceApproved': false, + 'happyPlaceModerationReason': 'barista_moderation', + 'isImageApproved': true, + 'imageBatchId': 'batch-001', + 'imageUrl': 'https://example.com/image.png', + 'status': 'submitted', + 'baristaId': 'barista-jon', + 'orderSubmittedTime': '2023-10-10T10:10:10.000Z', + 'completionTime': '2023-10-10T10:15:10.000Z', + }; + + final metadata = LatteOrderMetadata.fromJson(json); + + expect(metadata.id, 'meta-123'); + expect(metadata.orderNumber, 42); + expect(metadata.status, LatteOrderStatus.submitted); + + final serialized = metadata.toJson(); + expect(serialized['id'], 'meta-123'); + expect(serialized['status'], 'submitted'); + expect(serialized['orderSubmittedTime'], '2023-10-10T10:10:10.000Z'); + }); + }); +} diff --git a/genlatte/genlatte-data/test/models/question_test.dart b/genlatte/genlatte-data/test/models/question_test.dart new file mode 100644 index 0000000..359d790 --- /dev/null +++ b/genlatte/genlatte-data/test/models/question_test.dart @@ -0,0 +1,105 @@ +// Copyright 2014 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:genlatte_data/models.dart'; +import 'package:test/test.dart'; + +void main() { + group('Question Serialization', () { + test('JSON serialization is correct for a TextQuestion', () { + final json = { + 'type': 'textQuestion', + 'id': 'q_name', + 'question': 'What is your name?', + 'helpText': 'Please enter your first name', + 'answer': 'Alice', + }; + + final question = Question.fromJson(json) as TextQuestion; + + expect(question.id, 'q_name'); + expect(question.body, 'What is your name?'); + expect(question.helpText, 'Please enter your first name'); + expect(question.answer, 'Alice'); + + final serialized = question.toJson(); + expect(serialized['type'], 'textQuestion'); + expect(serialized['id'], 'q_name'); + expect(serialized['question'], 'What is your name?'); + expect(serialized['helpText'], 'Please enter your first name'); + expect(serialized['answer'], 'Alice'); + }); + + test('JSON serialization is correct for a MultipleChoiceQuestion', () { + final json = { + 'type': 'multipleChoiceQuestion', + 'id': 'q_milk', + 'question': 'What milk do you want?', + 'acceptableAnswers': ['Oat', 'Almond', 'Whole'], + 'selectedValue': 'Oat', + }; + + final question = Question.fromJson(json) as MultipleChoiceQuestion; + + expect(question.id, 'q_milk'); + expect(question.body, 'What milk do you want?'); + expect(question.acceptableAnswers, ['Oat', 'Almond', 'Whole']); + expect(question.selectedValue, 'Oat'); + + final serialized = question.toJson(); + expect(serialized['type'], 'multipleChoiceQuestion'); + expect(serialized['question'], 'What milk do you want?'); + expect(serialized['acceptableAnswers'], ['Oat', 'Almond', 'Whole']); + expect(serialized['selectedValue'], 'Oat'); + }); + + test('JSON serialization is correct for a ZeroToOneQuestion', () { + final json = { + 'type': 'zeroToOneQuestion', + 'id': 'q_roast', + 'question': 'How dark do you want your roast?', + 'minValueLabel': 'Light', + 'maxValueLabel': 'Dark', + 'selectedValue': 0.75, + }; + + final question = Question.fromJson(json) as ZeroToOneQuestion; + + expect(question.id, 'q_roast'); + expect(question.body, 'How dark do you want your roast?'); + expect(question.minValueLabel, 'Light'); + expect(question.maxValueLabel, 'Dark'); + expect(question.selectedValue, 0.75); + + final serialized = question.toJson(); + expect(serialized['type'], 'zeroToOneQuestion'); + expect(serialized['question'], 'How dark do you want your roast?'); + expect(serialized['selectedValue'], 0.75); + }); + + test('JSON serialization is correct for a NegativeOneToOneQuestion', () { + final json = { + 'type': 'negativeOneToOneQuestion', + 'id': 'q_brightness', + 'question': 'Adjust brightness', + 'minValueLabel': 'Darker', + 'maxValueLabel': 'Brighter', + 'selectedValue': -0.5, + }; + + final question = Question.fromJson(json) as NegativeOneToOneQuestion; + + expect(question.id, 'q_brightness'); + expect(question.body, 'Adjust brightness'); + expect(question.minValueLabel, 'Darker'); + expect(question.maxValueLabel, 'Brighter'); + expect(question.selectedValue, -0.5); + + final serialized = question.toJson(); + expect(serialized['type'], 'negativeOneToOneQuestion'); + expect(serialized['question'], 'Adjust brightness'); + expect(serialized['selectedValue'], -0.5); + }); + }); +} diff --git a/genlatte/genlatte-ui/.fvmrc b/genlatte/genlatte-ui/.fvmrc new file mode 100644 index 0000000..09ade4c --- /dev/null +++ b/genlatte/genlatte-ui/.fvmrc @@ -0,0 +1,3 @@ +{ + "flutter": "3.43.0-0.3.pre" +} \ No newline at end of file diff --git a/genlatte/genlatte-ui/.gitignore b/genlatte/genlatte-ui/.gitignore new file mode 100644 index 0000000..75a6258 --- /dev/null +++ b/genlatte/genlatte-ui/.gitignore @@ -0,0 +1,50 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ +/coverage/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release + +# FVM Version Cache +.fvm/** + +.agents/skills/ \ No newline at end of file diff --git a/genlatte/genlatte-ui/.metadata b/genlatte/genlatte-ui/.metadata new file mode 100644 index 0000000..112be7c --- /dev/null +++ b/genlatte/genlatte-ui/.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: "aa1d2edd012bbf31e58827bf3ac7010c10991e20" + channel: "beta" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: aa1d2edd012bbf31e58827bf3ac7010c10991e20 + base_revision: aa1d2edd012bbf31e58827bf3ac7010c10991e20 + - platform: android + create_revision: aa1d2edd012bbf31e58827bf3ac7010c10991e20 + base_revision: aa1d2edd012bbf31e58827bf3ac7010c10991e20 + + # 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/genlatte/genlatte-ui/CONTEXT.md b/genlatte/genlatte-ui/CONTEXT.md new file mode 100644 index 0000000..116b415 --- /dev/null +++ b/genlatte/genlatte-ui/CONTEXT.md @@ -0,0 +1,44 @@ +# GenLatte UI Package (`genlatte-ui/`) + +## Purpose +This directory contains the core Flutter package for the GenLatte project. It provides the design system, shared widgets, and reusable UI logic used across the Kiosk, Barista, and Moderator interfaces. + +## Detailed File Overviews + +- **`pubspec.yaml`**: + - **Description**: The Flutter manifest for the UI package. + - **Key Dependencies**: + - `shadcn_flutter`: The foundational UI component library. + - `flutter_bloc`: For standardized state management throughout the application. + - `google_fonts`: For typography (specifically Google Sans). + +- **`lib/src/`**: + - **Description**: Contains the private implementation of the package. + - **Modules**: + - **`core/`**: Infrastructure including routing, constants, and global state management. + - **`widgets/`**: Reusable design system primitives like `GenLatteScaffold` and `ResponsiveSizedBox`. + - **`screens/`**: UI implementation for specific features (Kiosk home, Recent Orders, etc.). + +- **`widgetbook/`**: + - **Description**: A documentation and testing harness for the package's widgets (see [widgetbook/CONTEXT.md](widgetbook/CONTEXT.md)). + +- **`scripts/`**: + - **Description**: Convenience scripts and symbolic links for development automation (see [scripts/CONTEXT.md](scripts/CONTEXT.md)). + +## Dependencies/Relationships + +- **Backend (Firebase)**: Interacts with Firebase via the source and data layers defined in `lib/src/sources` and `lib/src/data`. +- **Responsive Layout**: Heavily relies on `LayoutProvider` and `DesignScalar` to ensure a consistent experience across desktop, tablet, and mobile orientations. + +## Usage/Exports + +### Package Integration +This package is intended to be imported by consumer Flutter applications: +```yaml +dependencies: + genlatte_ui: + path: ./genlatte-ui +``` + +### UI Standards +Developers should follow the guidelines in the [ui-standard](.agents/skills/ui-standard/SKILL.md) skill to maintain design consistency when adding new components or screens. diff --git a/genlatte/genlatte-ui/GoogleService-Info.plist b/genlatte/genlatte-ui/GoogleService-Info.plist new file mode 100644 index 0000000..04df032 --- /dev/null +++ b/genlatte/genlatte-ui/GoogleService-Info.plist @@ -0,0 +1,32 @@ + + + + + API_KEY + AIzaSyA6AlJnu87HVk3LtCtxBtk8hsyNJEJO_9I + GCM_SENDER_ID + 177381243786 + PLIST_VERSION + 1 + BUNDLE_ID + com.google.genlatte + PROJECT_ID + gcdemos-26-int-dd-latteart + STORAGE_BUCKET + gcdemos-26-int-dd-latteart.firebasestorage.app + IS_ADS_ENABLED + + IS_ANALYTICS_ENABLED + + IS_APPINVITE_ENABLED + + IS_GCM_ENABLED + + IS_SIGNIN_ENABLED + + GOOGLE_APP_ID + 1:177381243786:ios:b46a45bfd7ecca11f6e673 + DATABASE_URL + https://gcdemos-26-int-dd-latteart-default-rtdb.firebaseio.com + + \ No newline at end of file diff --git a/genlatte/genlatte-ui/Makefile b/genlatte/genlatte-ui/Makefile new file mode 100644 index 0000000..d8c2955 --- /dev/null +++ b/genlatte/genlatte-ui/Makefile @@ -0,0 +1,42 @@ +app: + fvm flutter run -d web-server --web-port 8080 + +app/kiosk: app + +app/kiosk/wasm: + fvm flutter run -d web-server --wasm --web-port 8080 + +app/barista: + fvm flutter run -d web-server --web-port 8083 + +app/moderator: + fvm flutter run -d web-server --web-port 8085 + +app/queue: + fvm flutter run -d web-server --web-port 8084 + +app/queue/wasm: + fvm flutter run -d web-server --wasm --web-port 8084 + +app/recent: + fvm flutter run -d web-server --web-port 8082 + +app/recent/wasm: + fvm flutter run -d web-server --wasm --web-port 8082 + +gen/ui: + fvm dart run build_runner build --delete-conflicting-outputs + +gen/data: + cd ../genlatte-data && fvm dart run build_runner build --delete-conflicting-outputs + +gen: + $(MAKE) gen/data + $(MAKE) gen/ui + +gen/widgetbook: + cd widgetbook && fvm dart run build_runner build -d + +.PHONY: widgetbook +widgetbook: + cd widgetbook && fvm flutter run -d web-server --web-port 8081 \ No newline at end of file diff --git a/genlatte/genlatte-ui/README.md b/genlatte/genlatte-ui/README.md new file mode 100644 index 0000000..174a834 --- /dev/null +++ b/genlatte/genlatte-ui/README.md @@ -0,0 +1,3 @@ +# genlatte + +A new Flutter project. diff --git a/genlatte/genlatte-ui/analysis_options.yaml b/genlatte/genlatte-ui/analysis_options.yaml new file mode 100644 index 0000000..fcf0b22 --- /dev/null +++ b/genlatte/genlatte-ui/analysis_options.yaml @@ -0,0 +1,17 @@ +include: package:very_good_analysis/analysis_options.yaml +linter: + rules: + public_member_api_docs: true + omit_local_variable_types: false + document_ignores: true + +analyzer: + errors: + todo: ignore + document_ignores: ignore + exclude: + - "**.freezed.dart" + - "**.g.dart" + +formatter: + trailing_commas: preserve \ No newline at end of file diff --git a/genlatte/genlatte-ui/assets/CONTEXT.md b/genlatte/genlatte-ui/assets/CONTEXT.md new file mode 100644 index 0000000..3bdd35b --- /dev/null +++ b/genlatte/genlatte-ui/assets/CONTEXT.md @@ -0,0 +1,12 @@ +# Static Asset Root + +**Purpose:** +The primary container for all binary and configuration assets bundled with the Flutter application. + +**Subdirectories:** +- `videos/`: High-resolution loading and background animations. +- `shaders/`: GLSL fragment shaders for advanced UI effects. +- `avatars/`: Pre-rendered persona images for user identification. + +**Dependencies/Relationships:** +- Assets are declared in `genlatte-ui/pubspec.yaml` to be included in the application bundle. diff --git a/genlatte/genlatte-ui/assets/avatars/CONTEXT.md b/genlatte/genlatte-ui/assets/avatars/CONTEXT.md new file mode 100644 index 0000000..1b11578 --- /dev/null +++ b/genlatte/genlatte-ui/assets/avatars/CONTEXT.md @@ -0,0 +1,10 @@ +# User Avatar Assets + +**Purpose:** +Provides a set of diverse, pre-rendered avatar images used for barista and customer profiles. + +**Detailed File Overviews:** +- `asianFemale.png`, `asianMale.png`, `blackFemale.png`, etc.: High-quality PNG assets representing different personas to provide a localized and inclusive user experience. + +**Dependencies/Relationships:** +- Displayed in the `Kiosk`, `Barista`, and `Moderator` views. diff --git a/genlatte/genlatte-ui/assets/avatars/asianFemale.png b/genlatte/genlatte-ui/assets/avatars/asianFemale.png new file mode 100644 index 0000000..6e08c65 Binary files /dev/null and b/genlatte/genlatte-ui/assets/avatars/asianFemale.png differ diff --git a/genlatte/genlatte-ui/assets/avatars/asianMale.png b/genlatte/genlatte-ui/assets/avatars/asianMale.png new file mode 100644 index 0000000..2e30891 Binary files /dev/null and b/genlatte/genlatte-ui/assets/avatars/asianMale.png differ diff --git a/genlatte/genlatte-ui/assets/avatars/blackFemale.png b/genlatte/genlatte-ui/assets/avatars/blackFemale.png new file mode 100644 index 0000000..218409d Binary files /dev/null and b/genlatte/genlatte-ui/assets/avatars/blackFemale.png differ diff --git a/genlatte/genlatte-ui/assets/avatars/blackMale.png b/genlatte/genlatte-ui/assets/avatars/blackMale.png new file mode 100644 index 0000000..28e4b15 Binary files /dev/null and b/genlatte/genlatte-ui/assets/avatars/blackMale.png differ diff --git a/genlatte/genlatte-ui/assets/avatars/caucasianFemale.png b/genlatte/genlatte-ui/assets/avatars/caucasianFemale.png new file mode 100644 index 0000000..4d73f6b Binary files /dev/null and b/genlatte/genlatte-ui/assets/avatars/caucasianFemale.png differ diff --git a/genlatte/genlatte-ui/assets/avatars/caucasianMale.png b/genlatte/genlatte-ui/assets/avatars/caucasianMale.png new file mode 100644 index 0000000..29fcf5d Binary files /dev/null and b/genlatte/genlatte-ui/assets/avatars/caucasianMale.png differ diff --git a/genlatte/genlatte-ui/assets/avatars/hispanicFemale.png b/genlatte/genlatte-ui/assets/avatars/hispanicFemale.png new file mode 100644 index 0000000..abf32d1 Binary files /dev/null and b/genlatte/genlatte-ui/assets/avatars/hispanicFemale.png differ diff --git a/genlatte/genlatte-ui/assets/avatars/hispanicMale.png b/genlatte/genlatte-ui/assets/avatars/hispanicMale.png new file mode 100644 index 0000000..e39158f Binary files /dev/null and b/genlatte/genlatte-ui/assets/avatars/hispanicMale.png differ diff --git a/genlatte/genlatte-ui/assets/avatars/indianFemale.png b/genlatte/genlatte-ui/assets/avatars/indianFemale.png new file mode 100644 index 0000000..c08b2f6 Binary files /dev/null and b/genlatte/genlatte-ui/assets/avatars/indianFemale.png differ diff --git a/genlatte/genlatte-ui/assets/avatars/indianMale.png b/genlatte/genlatte-ui/assets/avatars/indianMale.png new file mode 100644 index 0000000..cc023b4 Binary files /dev/null and b/genlatte/genlatte-ui/assets/avatars/indianMale.png differ diff --git a/genlatte/genlatte-ui/assets/firebase-logo.png b/genlatte/genlatte-ui/assets/firebase-logo.png new file mode 100644 index 0000000..5524d8d Binary files /dev/null and b/genlatte/genlatte-ui/assets/firebase-logo.png differ diff --git a/genlatte/genlatte-ui/assets/flutter-latte.png b/genlatte/genlatte-ui/assets/flutter-latte.png new file mode 100644 index 0000000..d28f9aa Binary files /dev/null and b/genlatte/genlatte-ui/assets/flutter-latte.png differ diff --git a/genlatte/genlatte-ui/assets/flutter-logo.png b/genlatte/genlatte-ui/assets/flutter-logo.png new file mode 100644 index 0000000..4d545bd Binary files /dev/null and b/genlatte/genlatte-ui/assets/flutter-logo.png differ diff --git a/genlatte/genlatte-ui/assets/gemini-logo.png b/genlatte/genlatte-ui/assets/gemini-logo.png new file mode 100644 index 0000000..6126770 Binary files /dev/null and b/genlatte/genlatte-ui/assets/gemini-logo.png differ diff --git a/genlatte/genlatte-ui/assets/green-check.svg b/genlatte/genlatte-ui/assets/green-check.svg new file mode 100644 index 0000000..f82dffb --- /dev/null +++ b/genlatte/genlatte-ui/assets/green-check.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/genlatte/genlatte-ui/assets/io-primary-gradient-mesh-small.jpg b/genlatte/genlatte-ui/assets/io-primary-gradient-mesh-small.jpg new file mode 100644 index 0000000..1d4913c Binary files /dev/null and b/genlatte/genlatte-ui/assets/io-primary-gradient-mesh-small.jpg differ diff --git a/genlatte/genlatte-ui/assets/latte-background-thumb.png b/genlatte/genlatte-ui/assets/latte-background-thumb.png new file mode 100644 index 0000000..0dc7510 Binary files /dev/null and b/genlatte/genlatte-ui/assets/latte-background-thumb.png differ diff --git a/genlatte/genlatte-ui/assets/latte-background.png b/genlatte/genlatte-ui/assets/latte-background.png new file mode 100644 index 0000000..77cef4c Binary files /dev/null and b/genlatte/genlatte-ui/assets/latte-background.png differ diff --git a/genlatte/genlatte-ui/assets/shaders/CONTEXT.md b/genlatte/genlatte-ui/assets/shaders/CONTEXT.md new file mode 100644 index 0000000..9e6954e --- /dev/null +++ b/genlatte/genlatte-ui/assets/shaders/CONTEXT.md @@ -0,0 +1,11 @@ +# Custom GPU Shaders + +**Purpose:** +Contains GLSL fragment shaders utilized by the Flutter engine for high-performance visual effects. + +**Detailed File Overviews:** +- `squish.glsl`: A custom shader used to implement the physics-based "squish" effect on latte images, allowing for realistic deformations during collisions or interactions. + +**Dependencies/Relationships:** +- Integrated into the Flutter build process via the `pubspec.yaml` shader definitions. +- Utilized by the `SquishableLatteImage` widget. diff --git a/genlatte/genlatte-ui/assets/shaders/squish.glsl b/genlatte/genlatte-ui/assets/shaders/squish.glsl new file mode 100644 index 0000000..40cda3f --- /dev/null +++ b/genlatte/genlatte-ui/assets/shaders/squish.glsl @@ -0,0 +1,59 @@ +#version 460 core + +precision mediump float; + +#include + +uniform vec2 uSize; +uniform float uAngle; +uniform float uSquishFactor; + +uniform sampler2D tTexture; + +out vec4 fragColor; + +void fragment(vec2 uv, vec2 pos, inout vec4 color) { + + vec2 transformedUV = uv.xy; + + transformedUV -= 0.5; + + // float angle = radians(uSquishAngleOrigin); + + float cosAngle = cos(uAngle); + float sinAngle = sin(uAngle); + mat2 rotationMatrix = mat2(cosAngle, -sinAngle, sinAngle, cosAngle); + + transformedUV = rotationMatrix * transformedUV; + + // Utilizing the original proportional logic but shifting uSquishFactor so that 0 is the break-even point. + // A positive effect strength maps to a smaller squishFactor. + float squishFactor = 1.0 - uSquishFactor; + float halfSquishFactor = ((squishFactor - 1.0) * 0.5) + 1.0; + + // Squish the axes using the original logic ratio + transformedUV.y *= squishFactor; + transformedUV.x *= (1.0 / halfSquishFactor); + + mat2 inverseRotationMatrix = mat2(cosAngle, sinAngle, -sinAngle, cosAngle); + transformedUV = inverseRotationMatrix * transformedUV; + + transformedUV += 0.5; + + if(transformedUV.x < 0.0 || transformedUV.x > 1.0 || transformedUV.y < 0.0 || transformedUV.y > 1.0) { + color = vec4(0.0, 0.0, 0.0, 0.0); + return; + } + + color = texture(tTexture, transformedUV); +} + +void main() { + vec2 pos = FlutterFragCoord().xy; + vec2 uv = pos / uSize; + vec4 color; + + fragment(uv, pos, color); + + fragColor = color; +} \ No newline at end of file diff --git a/genlatte/genlatte-ui/assets/videos/CONTEXT.md b/genlatte/genlatte-ui/assets/videos/CONTEXT.md new file mode 100644 index 0000000..3fe6574 --- /dev/null +++ b/genlatte/genlatte-ui/assets/videos/CONTEXT.md @@ -0,0 +1,10 @@ +# UI Video Assets + +**Purpose:** +Stores high-resolution video assets used for loading screens and background animations in the Flutter application. + +**Detailed File Overviews:** +- `Dash_Loading_1080px.webm`: An optimized video file featuring the Dash mascot, used during long-running async operations or initial application load. + +**Dependencies/Relationships:** +- Loaded by video player widgets in `genlatte-ui/lib/src/widgets/`. diff --git a/genlatte/genlatte-ui/assets/videos/Dash_Loading_1080px.webm b/genlatte/genlatte-ui/assets/videos/Dash_Loading_1080px.webm new file mode 100644 index 0000000..299b6da Binary files /dev/null and b/genlatte/genlatte-ui/assets/videos/Dash_Loading_1080px.webm differ diff --git a/genlatte/genlatte-ui/assets/videos/Dash_Loading_1080px_H.265.mov b/genlatte/genlatte-ui/assets/videos/Dash_Loading_1080px_H.265.mov new file mode 100644 index 0000000..8b9febd Binary files /dev/null and b/genlatte/genlatte-ui/assets/videos/Dash_Loading_1080px_H.265.mov differ diff --git a/genlatte/genlatte-ui/devtools_options.yaml b/genlatte/genlatte-ui/devtools_options.yaml new file mode 100644 index 0000000..fa0b357 --- /dev/null +++ b/genlatte/genlatte-ui/devtools_options.yaml @@ -0,0 +1,3 @@ +description: This file stores settings for Dart & Flutter DevTools. +documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states +extensions: diff --git a/genlatte/genlatte-ui/firebase.json b/genlatte/genlatte-ui/firebase.json new file mode 100644 index 0000000..9d40c9e --- /dev/null +++ b/genlatte/genlatte-ui/firebase.json @@ -0,0 +1,14 @@ +{ + "flutter": { + "platforms": { + "dart": { + "lib/firebase_options.dart": { + "projectId": "gcdemos-26-int-dd-latteart", + "configurations": { + "web": "1:177381243786:web:dea75ce782190ef7f6e673" + } + } + } + } + } +} \ No newline at end of file diff --git a/genlatte/genlatte-ui/lib/CONTEXT.md b/genlatte/genlatte-ui/lib/CONTEXT.md new file mode 100644 index 0000000..de5840a --- /dev/null +++ b/genlatte/genlatte-ui/lib/CONTEXT.md @@ -0,0 +1,15 @@ +# Application Library Root + +**Purpose:** +The primary source code root for the Flutter application. + +**Detailed File Overviews:** +- `firebase_options.dart`: Autogenerated Firebase configuration for all supported platforms (Web, iOS, etc.). +- `main.dart`: The main entry point that executes the `bootstrap` process. + +**Subdirectories:** +- `src/`: Encompasses the core logic, UI screens, and features of the app. +- `hive/`: Configuration for local data persistence. + +**Dependencies/Relationships:** +- Entry point for the entire presentation layer. diff --git a/genlatte/genlatte-ui/lib/firebase_options.dart b/genlatte/genlatte-ui/lib/firebase_options.dart new file mode 100644 index 0000000..96c1732 --- /dev/null +++ b/genlatte/genlatte-ui/lib/firebase_options.dart @@ -0,0 +1,88 @@ +// Copyright 2014 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. + +// File generated by FlutterFire CLI. +// ignore_for_file: type=lint +import 'package:firebase_core/firebase_core.dart' show FirebaseOptions; +import 'package:flutter/foundation.dart' + show defaultTargetPlatform, kIsWeb, TargetPlatform; + +/// Default [FirebaseOptions] for use with your Firebase apps. +/// +/// Example: +/// ```dart +/// import 'firebase_options.dart'; +/// // ... +/// await Firebase.initializeApp( +/// options: DefaultFirebaseOptions.currentPlatform, +/// ); +/// ``` +class DefaultFirebaseOptions { + static FirebaseOptions get currentPlatform { + if (kIsWeb) { + return web; + } + switch (defaultTargetPlatform) { + case TargetPlatform.android: + return android; + case TargetPlatform.iOS: + return ios; + case TargetPlatform.macOS: + throw UnsupportedError( + 'DefaultFirebaseOptions have not been configured for macos - ' + 'you can reconfigure this by running the FlutterFire CLI again.', + ); + case TargetPlatform.windows: + throw UnsupportedError( + 'DefaultFirebaseOptions have not been configured for windows - ' + 'you can reconfigure this by running the FlutterFire CLI again.', + ); + case TargetPlatform.linux: + throw UnsupportedError( + 'DefaultFirebaseOptions have not been configured for linux - ' + 'you can reconfigure this by running the FlutterFire CLI again.', + ); + default: + throw UnsupportedError( + 'DefaultFirebaseOptions are not supported for this platform.', + ); + } + } + + static const FirebaseOptions web = FirebaseOptions( + apiKey: 'AIzaSyA0mu0fBBQs2lNVCW861kkDTQSNcmBQjP4', + appId: '1:177381243786:web:dea75ce782190ef7f6e673', + messagingSenderId: '177381243786', + projectId: 'gcdemos-26-int-dd-latteart', + authDomain: 'gcdemos-26-int-dd-latteart.firebaseapp.com', + databaseURL: + 'https://gcdemos-26-int-dd-latteart-default-rtdb.firebaseio.com', + storageBucket: 'gcdemos-26-int-dd-latteart.firebasestorage.app', + measurementId: "G-KHJ9QF44VC", + ); + + static const FirebaseOptions ios = FirebaseOptions( + apiKey: 'AIzaSyA6AlJnu87HVk3LtCtxBtk8hsyNJEJO_9I', + appId: '1:177381243786:ios:b46a45bfd7ecca11f6e673', + messagingSenderId: '177381243786', + projectId: 'gcdemos-26-int-dd-latteart', + authDomain: 'gcdemos-26-int-dd-latteart.firebaseapp.com', + databaseURL: + 'https://gcdemos-26-int-dd-latteart-default-rtdb.firebaseio.com', + storageBucket: 'gcdemos-26-int-dd-latteart.firebasestorage.app', + measurementId: "G-KHJ9QF44VC", + ); + + static const FirebaseOptions android = FirebaseOptions( + apiKey: 'AIzaSyAhqEHefNYgZ_GgKnnqhieD7O_qDvclbEI', + appId: '1:177381243786:android:38afc480de87d7b2f6e673', + messagingSenderId: '177381243786', + projectId: 'gcdemos-26-int-dd-latteart', + authDomain: 'gcdemos-26-int-dd-latteart.firebaseapp.com', + databaseURL: + 'https://gcdemos-26-int-dd-latteart-default-rtdb.firebaseio.com', + storageBucket: 'gcdemos-26-int-dd-latteart.firebasestorage.app', + measurementId: "G-KHJ9QF44VC", + ); +} diff --git a/genlatte/genlatte-ui/lib/hive/CONTEXT.md b/genlatte/genlatte-ui/lib/hive/CONTEXT.md new file mode 100644 index 0000000..2a92c49 --- /dev/null +++ b/genlatte/genlatte-ui/lib/hive/CONTEXT.md @@ -0,0 +1,14 @@ +# Local Storage Persistence (Hive) + +**Purpose:** +Configures Hive for local NoSQL persistence, which caches data on the device to improve performance and allow for offline functionality. + +**Detailed File Overviews:** +- `hive_adapters.dart`: Central registration point for Hive TypeAdapters. It specifies which domain models (from `genlatte-data`) should be serialized by Hive. +- `hive_adapters.g.dart`: Autogenerated serialization logic. +- `hive_registrar.g.dart`: Autogenerated registration boilerplate. +- `AppHiveInitializer`: Coordinates the `Hive.initFlutter()` process and signals when local storage is ready for use. + +**Dependencies/Relationships:** +- Bridges `genlatte-data` models with the `hive_ce` storage engine. +- Used by the `HiveSources` in the data layer. diff --git a/genlatte/genlatte-ui/lib/hive/hive_adapters.dart b/genlatte/genlatte-ui/lib/hive/hive_adapters.dart new file mode 100644 index 0000000..0c2d5d5 --- /dev/null +++ b/genlatte/genlatte-ui/lib/hive/hive_adapters.dart @@ -0,0 +1,48 @@ +// Copyright 2014 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:data_layer_hive/data_layer_hive.dart'; +import 'package:genlatte/hive/hive_registrar.g.dart'; +import 'package:genlatte_data/models.dart'; +import 'package:hive_ce_flutter/hive_flutter.dart'; +import 'package:logging/logging.dart'; + +@GenerateAdapters([ + AdapterSpec(), + AdapterSpec(), + AdapterSpec(), + AdapterSpec(), + AdapterSpec(), + AdapterSpec(), + AdapterSpec(), + AdapterSpec(), + AdapterSpec(), + AdapterSpec(), +]) +part 'hive_adapters.g.dart'; + +final _log = Logger('HiveInitializer'); + +/// {@template AppHiveInitializer} +/// Class which exists to call `Hive.initFlutter` and `Hive.registerAdapters` +/// and report back when both are complete. +/// {@endtemplate} +class AppHiveInitializer extends HiveInitializer { + /// {@macro AppHiveInitializer} + AppHiveInitializer() { + initialize(); + } + + @override + Future performInitialization() { + return Hive.initFlutter().then((_) { + try { + Hive.registerAdapters(); + markReady(null); + } on Exception catch (e, st) { + _log.shout('Failed to initialize Hive: $e :: $st'); + } + }); + } +} diff --git a/genlatte/genlatte-ui/lib/hive/hive_adapters.g.dart b/genlatte/genlatte-ui/lib/hive/hive_adapters.g.dart new file mode 100644 index 0000000..c658a80 --- /dev/null +++ b/genlatte/genlatte-ui/lib/hive/hive_adapters.g.dart @@ -0,0 +1,503 @@ +// Copyright 2014 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. + +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'hive_adapters.dart'; + +// ************************************************************************** +// AdaptersGenerator +// ************************************************************************** + +class LatteOrderAdapter extends TypeAdapter { + @override + final typeId = 0; + + @override + LatteOrder read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return LatteOrder( + id: fields[0] as String?, + name: fields[1] as String?, + milk: fields[2] as String?, + sweetener: fields[3] as String?, + happyPlace: fields[4] as String?, + ); + } + + @override + void write(BinaryWriter writer, LatteOrder obj) { + writer + ..writeByte(5) + ..writeByte(0) + ..write(obj.id) + ..writeByte(1) + ..write(obj.name) + ..writeByte(2) + ..write(obj.milk) + ..writeByte(3) + ..write(obj.sweetener) + ..writeByte(4) + ..write(obj.happyPlace); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is LatteOrderAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} + +class LatteOrderMetadataAdapter extends TypeAdapter { + @override + final typeId = 1; + + @override + LatteOrderMetadata read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return LatteOrderMetadata( + id: fields[0] as String?, + orderNumber: (fields[1] as num?)?.toInt(), + isNameApproved: fields[2] as bool?, + isHappyPlaceApproved: fields[3] as bool?, + happyPlaceModerationReason: fields[4] as String?, + isImageApproved: fields[5] as bool?, + imageBatchId: fields[6] as String?, + imageUrl: fields[7] as String?, + status: fields[8] == null + ? LatteOrderStatus.configuring + : fields[8] as LatteOrderStatus, + baristaId: fields[11] as String?, + orderSubmittedTime: fields[9] as DateTime?, + completionTime: fields[10] as DateTime?, + ); + } + + @override + void write(BinaryWriter writer, LatteOrderMetadata obj) { + writer + ..writeByte(12) + ..writeByte(0) + ..write(obj.id) + ..writeByte(1) + ..write(obj.orderNumber) + ..writeByte(2) + ..write(obj.isNameApproved) + ..writeByte(3) + ..write(obj.isHappyPlaceApproved) + ..writeByte(4) + ..write(obj.happyPlaceModerationReason) + ..writeByte(5) + ..write(obj.isImageApproved) + ..writeByte(6) + ..write(obj.imageBatchId) + ..writeByte(7) + ..write(obj.imageUrl) + ..writeByte(8) + ..write(obj.status) + ..writeByte(9) + ..write(obj.orderSubmittedTime) + ..writeByte(10) + ..write(obj.completionTime) + ..writeByte(11) + ..write(obj.baristaId); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is LatteOrderMetadataAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} + +class LatteOrderStatusAdapter extends TypeAdapter { + @override + final typeId = 2; + + @override + LatteOrderStatus read(BinaryReader reader) { + switch (reader.readByte()) { + case 0: + return LatteOrderStatus.configuring; + case 1: + return LatteOrderStatus.submitted; + case 2: + return LatteOrderStatus.validated; + case 3: + return LatteOrderStatus.inProgress; + case 4: + return LatteOrderStatus.completed; + case 5: + return LatteOrderStatus.archived; + default: + return LatteOrderStatus.configuring; + } + } + + @override + void write(BinaryWriter writer, LatteOrderStatus obj) { + switch (obj) { + case LatteOrderStatus.configuring: + writer.writeByte(0); + case LatteOrderStatus.submitted: + writer.writeByte(1); + case LatteOrderStatus.validated: + writer.writeByte(2); + case LatteOrderStatus.inProgress: + writer.writeByte(3); + case LatteOrderStatus.completed: + writer.writeByte(4); + case LatteOrderStatus.archived: + writer.writeByte(5); + } + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is LatteOrderStatusAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} + +class LatteImageBatchAdapter extends TypeAdapter { + @override + final typeId = 3; + + @override + LatteImageBatch read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return LatteImageBatch( + id: fields[0] as String, + orderId: fields[1] as String, + image0: fields[5] as LatteImage?, + image1: fields[2] as LatteImage?, + image2: fields[3] as LatteImage?, + image3: fields[4] as LatteImage?, + parent: fields[6] as LatteImageBatchParent?, + ); + } + + @override + void write(BinaryWriter writer, LatteImageBatch obj) { + writer + ..writeByte(7) + ..writeByte(0) + ..write(obj.id) + ..writeByte(1) + ..write(obj.orderId) + ..writeByte(2) + ..write(obj.image1) + ..writeByte(3) + ..write(obj.image2) + ..writeByte(4) + ..write(obj.image3) + ..writeByte(5) + ..write(obj.image0) + ..writeByte(6) + ..write(obj.parent); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is LatteImageBatchAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} + +class LatteImageAdapter extends TypeAdapter { + @override + final typeId = 4; + + @override + LatteImage read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return LatteImage( + imageUrl: fields[0] as String, + prompt: fields[1] as String, + questions: (fields[2] as List?)?.cast(), + description: fields[3] as String, + ); + } + + @override + void write(BinaryWriter writer, LatteImage obj) { + writer + ..writeByte(4) + ..writeByte(0) + ..write(obj.imageUrl) + ..writeByte(1) + ..write(obj.prompt) + ..writeByte(2) + ..write(obj.questions) + ..writeByte(3) + ..write(obj.description); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is LatteImageAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} + +class QuestionAdapter extends TypeAdapter { + @override + final typeId = 5; + + @override + Question read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return Question( + id: fields[0] as String, + body: fields[1] as String, + answer: fields[2] as String?, + ); + } + + @override + void write(BinaryWriter writer, Question obj) { + writer + ..writeByte(3) + ..writeByte(0) + ..write(obj.id) + ..writeByte(1) + ..write(obj.body) + ..writeByte(2) + ..write(obj.answer); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is QuestionAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} + +class BaristaAdapter extends TypeAdapter { + @override + final typeId = 6; + + @override + Barista read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return Barista( + username: fields[0] as String, + persona: fields[1] as BaristaPersona, + id: fields[2] as String?, + ); + } + + @override + void write(BinaryWriter writer, Barista obj) { + writer + ..writeByte(3) + ..writeByte(0) + ..write(obj.username) + ..writeByte(1) + ..write(obj.persona) + ..writeByte(2) + ..write(obj.id); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is BaristaAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} + +class LatteAdapter extends TypeAdapter { + @override + final typeId = 7; + + @override + Latte read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return Latte( + order: fields[0] as LatteOrder, + metadata: fields[1] as LatteOrderMetadata, + ); + } + + @override + void write(BinaryWriter writer, Latte obj) { + writer + ..writeByte(2) + ..writeByte(0) + ..write(obj.order) + ..writeByte(1) + ..write(obj.metadata); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is LatteAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} + +class BaristaPersonaAdapter extends TypeAdapter { + @override + final typeId = 8; + + @override + BaristaPersona read(BinaryReader reader) { + switch (reader.readByte()) { + case 0: + return BaristaPersona.blackFemale; + case 1: + return BaristaPersona.asianFemale; + case 2: + return BaristaPersona.caucasianFemale; + case 3: + return BaristaPersona.hispanicFemale; + case 4: + return BaristaPersona.indianFemale; + case 5: + return BaristaPersona.blackMale; + case 6: + return BaristaPersona.asianMale; + case 7: + return BaristaPersona.caucasianMale; + case 8: + return BaristaPersona.hispanicMale; + case 9: + return BaristaPersona.indianMale; + default: + return BaristaPersona.blackFemale; + } + } + + @override + void write(BinaryWriter writer, BaristaPersona obj) { + switch (obj) { + case BaristaPersona.blackFemale: + writer.writeByte(0); + case BaristaPersona.asianFemale: + writer.writeByte(1); + case BaristaPersona.caucasianFemale: + writer.writeByte(2); + case BaristaPersona.hispanicFemale: + writer.writeByte(3); + case BaristaPersona.indianFemale: + writer.writeByte(4); + case BaristaPersona.blackMale: + writer.writeByte(5); + case BaristaPersona.asianMale: + writer.writeByte(6); + case BaristaPersona.caucasianMale: + writer.writeByte(7); + case BaristaPersona.hispanicMale: + writer.writeByte(8); + case BaristaPersona.indianMale: + writer.writeByte(9); + } + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is BaristaPersonaAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} + +class MachineAdapter extends TypeAdapter { + @override + final typeId = 9; + + @override + Machine read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return Machine( + id: fields[0] as String, + name: fields[1] as String, + isActive: fields[3] == null ? true : fields[3] as bool, + isBlackAndWhite: fields[2] == null ? true : fields[2] as bool, + ); + } + + @override + void write(BinaryWriter writer, Machine obj) { + writer + ..writeByte(4) + ..writeByte(0) + ..write(obj.id) + ..writeByte(1) + ..write(obj.name) + ..writeByte(2) + ..write(obj.isBlackAndWhite) + ..writeByte(3) + ..write(obj.isActive); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is MachineAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} diff --git a/genlatte/genlatte-ui/lib/hive/hive_adapters.g.yaml b/genlatte/genlatte-ui/lib/hive/hive_adapters.g.yaml new file mode 100644 index 0000000..155fa43 --- /dev/null +++ b/genlatte/genlatte-ui/lib/hive/hive_adapters.g.yaml @@ -0,0 +1,157 @@ +# Generated by Hive CE +# Manual modifications may be necessary for certain migrations +# Check in to version control +nextTypeId: 10 +types: + LatteOrder: + typeId: 0 + nextIndex: 5 + fields: + id: + index: 0 + name: + index: 1 + milk: + index: 2 + sweetener: + index: 3 + happyPlace: + index: 4 + LatteOrderMetadata: + typeId: 1 + nextIndex: 12 + fields: + id: + index: 0 + orderNumber: + index: 1 + isNameApproved: + index: 2 + isHappyPlaceApproved: + index: 3 + happyPlaceModerationReason: + index: 4 + isImageApproved: + index: 5 + imageBatchId: + index: 6 + imageUrl: + index: 7 + status: + index: 8 + orderSubmittedTime: + index: 9 + completionTime: + index: 10 + baristaId: + index: 11 + LatteOrderStatus: + typeId: 2 + nextIndex: 6 + fields: + configuring: + index: 0 + submitted: + index: 1 + validated: + index: 2 + inProgress: + index: 3 + completed: + index: 4 + archived: + index: 5 + LatteImageBatch: + typeId: 3 + nextIndex: 7 + fields: + id: + index: 0 + orderId: + index: 1 + image1: + index: 2 + image2: + index: 3 + image3: + index: 4 + image0: + index: 5 + parent: + index: 6 + LatteImage: + typeId: 4 + nextIndex: 4 + fields: + imageUrl: + index: 0 + prompt: + index: 1 + questions: + index: 2 + description: + index: 3 + Question: + typeId: 5 + nextIndex: 3 + fields: + id: + index: 0 + body: + index: 1 + answer: + index: 2 + Barista: + typeId: 6 + nextIndex: 3 + fields: + username: + index: 0 + persona: + index: 1 + id: + index: 2 + Latte: + typeId: 7 + nextIndex: 2 + fields: + order: + index: 0 + metadata: + index: 1 + BaristaPersona: + typeId: 8 + nextIndex: 10 + fields: + blackFemale: + index: 0 + asianFemale: + index: 1 + caucasianFemale: + index: 2 + hispanicFemale: + index: 3 + indianFemale: + index: 4 + blackMale: + index: 5 + asianMale: + index: 6 + caucasianMale: + index: 7 + hispanicMale: + index: 8 + indianMale: + index: 9 + Machine: + typeId: 9 + nextIndex: 4 + fields: + id: + index: 0 + name: + index: 1 + isBlackAndWhite: + index: 2 + isActive: + index: 3 diff --git a/genlatte/genlatte-ui/lib/hive/hive_registrar.g.dart b/genlatte/genlatte-ui/lib/hive/hive_registrar.g.dart new file mode 100644 index 0000000..7956e10 --- /dev/null +++ b/genlatte/genlatte-ui/lib/hive/hive_registrar.g.dart @@ -0,0 +1,40 @@ +// Copyright 2014 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. + +// Generated by Hive CE +// Do not modify +// Check in to version control + +import 'package:hive_ce/hive_ce.dart'; +import 'package:genlatte/hive/hive_adapters.dart'; + +extension HiveRegistrar on HiveInterface { + void registerAdapters() { + registerAdapter(BaristaAdapter()); + registerAdapter(BaristaPersonaAdapter()); + registerAdapter(LatteAdapter()); + registerAdapter(LatteImageAdapter()); + registerAdapter(LatteImageBatchAdapter()); + registerAdapter(LatteOrderAdapter()); + registerAdapter(LatteOrderMetadataAdapter()); + registerAdapter(LatteOrderStatusAdapter()); + registerAdapter(MachineAdapter()); + registerAdapter(QuestionAdapter()); + } +} + +extension IsolatedHiveRegistrar on IsolatedHiveInterface { + void registerAdapters() { + registerAdapter(BaristaAdapter()); + registerAdapter(BaristaPersonaAdapter()); + registerAdapter(LatteAdapter()); + registerAdapter(LatteImageAdapter()); + registerAdapter(LatteImageBatchAdapter()); + registerAdapter(LatteOrderAdapter()); + registerAdapter(LatteOrderMetadataAdapter()); + registerAdapter(LatteOrderStatusAdapter()); + registerAdapter(MachineAdapter()); + registerAdapter(QuestionAdapter()); + } +} diff --git a/genlatte/genlatte-ui/lib/main.dart b/genlatte/genlatte-ui/lib/main.dart new file mode 100644 index 0000000..7dcda97 --- /dev/null +++ b/genlatte/genlatte-ui/lib/main.dart @@ -0,0 +1,12 @@ +// Copyright 2014 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. + +/// Convenience entrypoint to allow `$flutter run` to work during development. +/// During deployment, prefer one of the explicit entrypoints. +library; + +import 'package:genlatte/src/bootstrap.dart'; +import 'package:genlatte/src/core/core.dart'; + +Future main() async => bootstrap(appEnv: AppEnv.current); diff --git a/genlatte/genlatte-ui/lib/main_dev.dart b/genlatte/genlatte-ui/lib/main_dev.dart new file mode 100644 index 0000000..0e858fc --- /dev/null +++ b/genlatte/genlatte-ui/lib/main_dev.dart @@ -0,0 +1,9 @@ +// Copyright 2014 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:genlatte/src/bootstrap.dart'; +import 'package:genlatte/src/core/core.dart'; + +/// Entrypoint for the development environment. +Future main() async => bootstrap(appEnv: AppEnv.dev); diff --git a/genlatte/genlatte-ui/lib/main_prod.dart b/genlatte/genlatte-ui/lib/main_prod.dart new file mode 100644 index 0000000..dd88e5e --- /dev/null +++ b/genlatte/genlatte-ui/lib/main_prod.dart @@ -0,0 +1,9 @@ +// Copyright 2014 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:genlatte/src/bootstrap.dart'; +import 'package:genlatte/src/core/core.dart'; + +/// Entrypoint for the production environment. +Future main() async => bootstrap(appEnv: AppEnv.prod); diff --git a/genlatte/genlatte-ui/lib/main_staging.dart b/genlatte/genlatte-ui/lib/main_staging.dart new file mode 100644 index 0000000..826ae78 --- /dev/null +++ b/genlatte/genlatte-ui/lib/main_staging.dart @@ -0,0 +1,9 @@ +// Copyright 2014 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:genlatte/src/bootstrap.dart'; +import 'package:genlatte/src/core/core.dart'; + +/// Entrypoint for the staging environment. +Future main() async => bootstrap(appEnv: AppEnv.staging); diff --git a/genlatte/genlatte-ui/lib/src/CONTEXT.md b/genlatte/genlatte-ui/lib/src/CONTEXT.md new file mode 100644 index 0000000..e254845 --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/CONTEXT.md @@ -0,0 +1,17 @@ +# Application Source Core + +**Purpose:** +The centralized collection of the application's internal implementation details, from bootstrapping to feature-specific logic. + +**Detailed File Overviews:** +- `bootstrap.dart`: Orchestrates the application startup sequence, including Firebase initialization, dependency injection (using GetIt), Hive setup, and URL strategy configuration. +- `role.dart`: Defines the user roles and permissions logic utilized by the Auth and Routing systems. + +**Subdirectories:** +- `core/`: Foundation logic like Auth and Routing. +- `data/` / `sources/`: Data repositories and remote data providers. +- `screens/`: Feature-specific UI trees. +- `widgets/`: Shared UI components and primitives. + +**Dependencies/Relationships:** +- Synthesizes all internal modules into a cohesive application. diff --git a/genlatte/genlatte-ui/lib/src/bootstrap.dart b/genlatte/genlatte-ui/lib/src/bootstrap.dart new file mode 100644 index 0000000..151d003 --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/bootstrap.dart @@ -0,0 +1,108 @@ +// Copyright 2014 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:cloud_firestore/cloud_firestore.dart'; +import 'package:cloud_functions/cloud_functions.dart'; +import 'package:data_layer_hive/data_layer_hive.dart'; +import 'package:firebase_analytics/firebase_analytics.dart'; +import 'package:firebase_core/firebase_core.dart'; +import 'package:firebase_remote_config/firebase_remote_config.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_web_plugins/url_strategy.dart'; +import 'package:genlatte/firebase_options.dart'; +import 'package:genlatte/src/core/core.dart'; +import 'package:genlatte/src/screens/app/app.dart'; +import 'package:get_it/get_it.dart'; +import 'package:logging/logging.dart'; + +/// {@template bootstrap} +/// Application bootstrap. +/// {@endtemplate} +Future bootstrap({AppEnv? appEnv}) async { + WidgetsFlutterBinding.ensureInitialized(); + usePathUrlStrategy(); + + final env = appEnv ?? AppEnv.current; + + final defaultOptions = DefaultFirebaseOptions.currentPlatform; + final customMeasurementId = env.isProd + ? defaultOptions.measurementId + : 'G-51CFKR9L9R'; + + final options = FirebaseOptions( + apiKey: defaultOptions.apiKey, + appId: defaultOptions.appId, + messagingSenderId: defaultOptions.messagingSenderId, + projectId: defaultOptions.projectId, + authDomain: defaultOptions.authDomain, + databaseURL: defaultOptions.databaseURL, + storageBucket: defaultOptions.storageBucket, + measurementId: customMeasurementId, + ); + + final app = await Firebase.initializeApp( + options: options, + ); + + await setUpDependencyInjection( + appEnv: env, + firebaseFirestore: FirebaseFirestore.instanceFor( + app: app, + databaseId: 'gcdemos-26-int-dd-latteart-${env.name}', + ), + firebaseFunctions: FirebaseFunctions.instance, + firebaseAnalytics: FirebaseAnalytics.instance, + firebaseRemoteConfig: FirebaseRemoteConfig.instance, + ); + + // Register Hive adapaters and signal to HiveSources when that process + // is complete. + GetIt.I().initialize(); + + // await deleteAllDocumentsInCollection('latteOrders'); + // await deleteAllDocumentsInCollection('latteImageBatches'); + // await deleteAllDocumentsInCollection('latteOrderMetadata'); + // await deleteAllDocumentsInCollection('recentLatteImages'); + + await migrateOptionsCollection(); + + Logger.root.level = Level.FINE; // defaults to Level.INFO + Logger.root.onRecord.listen((record) { + debugPrint('${record.level.name}: ${record.time}: ${record.message}'); + }); + + runApp( + AppView( + authBloc: GetIt.I(), + appRouter: GetIt.I(), + ), + ); +} + +/// Helper function to delete all documents in a specified Firestore collection. +Future deleteAllDocumentsInCollection(String collectionName) async { + final firestore = GetIt.I(); + final snapshot = await firestore.collection(collectionName).get(); + await Future.wait(snapshot.docs.map((doc) => doc.reference.delete())); + debugPrint('Deleted ${snapshot.docs.length} documents from $collectionName'); +} + +/// Migrates the latteOptions collection to the new format. No-op once +/// already migrated. This can be deleted after the first application runs this +/// code against production. +Future migrateOptionsCollection() async { + final firestore = GetIt.I(); + final optionsSnapshot = await firestore.collection('latteOptions').get(); + for (final doc in optionsSnapshot.docs) { + final data = doc.data(); + final values = data['values'] as List?; + if (values != null && values.isNotEmpty && values.first is String) { + final newValues = values + .map((v) => {'name': v as String, 'isAvailable': true}) + .toList(); + await doc.reference.update({'values': newValues}); + debugPrint('Migrated LatteOptions document: ${doc.id}'); + } + } +} diff --git a/genlatte/genlatte-ui/lib/src/core/CONTEXT.md b/genlatte/genlatte-ui/lib/src/core/CONTEXT.md new file mode 100644 index 0000000..8de0da9 --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/core/CONTEXT.md @@ -0,0 +1,7 @@ +# Core App Sub-Tree + +**Purpose:** +A structural parent grouping directory that hosts fundamental architectural configurations globally required, but not tied to specific user paths (like Authentication and deep-linked Routing Maps). + +**Implementation Details:** +- **Child Contexts**: Please navigate into `auth/`, `routing/`, or `utils/` to see exact functional breakdowns for Identity management, Navigation interceptors, and Cloud helper logics. diff --git a/genlatte/genlatte-ui/lib/src/core/app_env.dart b/genlatte/genlatte-ui/lib/src/core/app_env.dart new file mode 100644 index 0000000..836fd0f --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/core/app_env.dart @@ -0,0 +1,40 @@ +// Copyright 2014 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 supported application environments. +enum AppEnv { + /// Local development environment. + dev, + + /// Shared staging environment. + staging, + + /// Production environment. + prod; + + /// Parses an [AppEnv] from a [String]. + static AppEnv fromString(String? value) { + return switch (value?.toLowerCase()) { + 'dev' => AppEnv.dev, + 'staging' => AppEnv.staging, + 'prod' => AppEnv.prod, + _ => AppEnv.staging, + }; + } + + /// Returns the current [AppEnv] based on the `APP_ENV` environment variable. + static AppEnv get current { + const value = String.fromEnvironment('APP_ENV'); + return fromString(value); + } + + /// Whether the current environment is [AppEnv.dev]. + bool get isDev => this == AppEnv.dev; + + /// Whether the current environment is [AppEnv.staging]. + bool get isStaging => this == AppEnv.staging; + + /// Whether the current environment is [AppEnv.prod]. + bool get isProd => this == AppEnv.prod; +} diff --git a/genlatte/genlatte-ui/lib/src/core/auth/CONTEXT.md b/genlatte/genlatte-ui/lib/src/core/auth/CONTEXT.md new file mode 100644 index 0000000..69f3ba9 --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/core/auth/CONTEXT.md @@ -0,0 +1,17 @@ +# Auth Core + +**Purpose:** +Provides the centralized identity management mechanism for the entire GenLatte UI application. + +**Detailed File Overviews:** + +- `auth.dart`: + - **Description**: Barrel export file. + +- `auth_bloc.dart`: + - **Description**: The BLoC controlling authentication state. + - **Core Logic**: Subscribes to `FirebaseAuth.authStateChanges()`. Upon receiving a new `User`, it forces an ID token refresh to retrieve custom claims directly from Firebase. These claims dictate the user's `Role` (`barista`, `kiosk`, `queue`, `recent`, `moderator`). If a user has no role, it automatically signs them out for security. + +**Dependencies/Relationships:** +- Tightly coupled with the `FirebaseAuth` plugin. +- Direct output is consumed by `core/routing/redirection.dart` to determine allowed URL paths. diff --git a/genlatte/genlatte-ui/lib/src/core/auth/auth.dart b/genlatte/genlatte-ui/lib/src/core/auth/auth.dart new file mode 100644 index 0000000..26bf4b3 --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/core/auth/auth.dart @@ -0,0 +1,5 @@ +// Copyright 2014 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 'auth_bloc.dart'; diff --git a/genlatte/genlatte-ui/lib/src/core/auth/auth_bloc.dart b/genlatte/genlatte-ui/lib/src/core/auth/auth_bloc.dart new file mode 100644 index 0000000..1111a9c --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/core/auth/auth_bloc.dart @@ -0,0 +1,122 @@ +// Copyright 2014 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:firebase_auth/firebase_auth.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:genlatte/src/role.dart'; +import 'package:logging/logging.dart'; + +part 'auth_bloc.freezed.dart'; + +final _log = Logger('AuthBloc'); + +typedef _Emit = Emitter; + +/// {@template AuthBloc} +/// Provider of application wide authentication state. +/// {@endtemplate} +class AuthBloc extends Bloc { + /// {@macro AuthBloc} + AuthBloc(this._authService) : super(AuthState.initial()) { + on( + (event, _Emit emit) => switch (event) { + NewUserEvent() => _onNewUserEvent(event, emit), + LoggedOutEvent() => _onLoggedOutEvent(event, emit), + }, + ); + + // Listen to authentication changes + _authSub = _authService.authStateChanges().listen(_onAuthChanges); + } + final FirebaseAuth _authService; + late final StreamSubscription _authSub; + + Future _onAuthChanges(User? user) async { + if (user != null) { + final role = await getCurrentUserClaims(user); + if (role == null) { + _log.warning('Signing out User ${user.uid} for having no role'); + await _authService.signOut(); + add(const LoggedOutEvent()); + return; + } + add(NewUserEvent(user, role)); + } else { + add(const LoggedOutEvent()); + } + } + + /// Fetches the user's current claims from Firebase. + Future getCurrentUserClaims(User user) async { + try { + // Force a token refresh to ensure claims are current + final IdTokenResult idTokenResult = await user.getIdTokenResult(); + + // Access the claims map + final Map? claims = idTokenResult.claims; + + if (claims == null) { + _log.severe('User ${user.uid} logged in without claims.'); + return null; + } + + if (claims['barista'] == true) { + return .barista; + } else if (claims['kiosk'] == true) { + return .kiosk; + } else if (claims['queue'] == true) { + return .queueObserver; + } else if (claims['recent'] == true) { + return .recentOrdersObserver; + } else if (claims['moderator'] == true) { + return .moderator; + } + + _log.severe('User ${user.uid} logged in with invalid role :: $claims'); + return null; + } on FirebaseAuthException catch (e) { + _log.severe('Error fetching ID token result: ${e.message}'); + return null; + } + } + + void _onNewUserEvent(NewUserEvent event, _Emit emit) => + emit(state.copyWith(user: event.user, role: event.role)); + + Future _onLoggedOutEvent(LoggedOutEvent event, _Emit emit) async { + emit(state.copyWith(user: null, role: null)); + } + + @override + Future close() async { + unawaited(_authSub.cancel()); + await super.close(); + } +} + +/// Actions that can be taken on the auth page. +@Freezed() +sealed class AuthEvent with _$AuthEvent { + const factory AuthEvent.newUser(User user, Role role) = NewUserEvent; + const factory AuthEvent.loggedOut() = LoggedOutEvent; +} + +/// {@template AuthState} +/// Complete representation of the auth page's state. +/// {@endtemplate +@Freezed() +sealed class AuthState with _$AuthState { + /// {@macro AuthState} + const factory AuthState({ + User? user, + Role? role, + }) = _AuthState; + const AuthState._(); + + /// Starter state fed to the [AuthBloc]. + factory AuthState.initial() => const AuthState(); +} diff --git a/genlatte/genlatte-ui/lib/src/core/auth/auth_bloc.freezed.dart b/genlatte/genlatte-ui/lib/src/core/auth/auth_bloc.freezed.dart new file mode 100644 index 0000000..61d15b6 --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/core/auth/auth_bloc.freezed.dart @@ -0,0 +1,532 @@ +// Copyright 2014 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. + +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'auth_bloc.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; +/// @nodoc +mixin _$AuthEvent { + + + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is AuthEvent); +} + + +@override +int get hashCode => runtimeType.hashCode; + +@override +String toString() { + return 'AuthEvent()'; +} + + +} + +/// @nodoc +class $AuthEventCopyWith<$Res> { +$AuthEventCopyWith(AuthEvent _, $Res Function(AuthEvent) __); +} + + +/// Adds pattern-matching-related methods to [AuthEvent]. +extension AuthEventPatterns on AuthEvent { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap({TResult Function( NewUserEvent value)? newUser,TResult Function( LoggedOutEvent value)? loggedOut,required TResult orElse(),}){ +final _that = this; +switch (_that) { +case NewUserEvent() when newUser != null: +return newUser(_that);case LoggedOutEvent() when loggedOut != null: +return loggedOut(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map({required TResult Function( NewUserEvent value) newUser,required TResult Function( LoggedOutEvent value) loggedOut,}){ +final _that = this; +switch (_that) { +case NewUserEvent(): +return newUser(_that);case LoggedOutEvent(): +return loggedOut(_that);} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull({TResult? Function( NewUserEvent value)? newUser,TResult? Function( LoggedOutEvent value)? loggedOut,}){ +final _that = this; +switch (_that) { +case NewUserEvent() when newUser != null: +return newUser(_that);case LoggedOutEvent() when loggedOut != null: +return loggedOut(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen({TResult Function( User user, Role role)? newUser,TResult Function()? loggedOut,required TResult orElse(),}) {final _that = this; +switch (_that) { +case NewUserEvent() when newUser != null: +return newUser(_that.user,_that.role);case LoggedOutEvent() when loggedOut != null: +return loggedOut();case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when({required TResult Function( User user, Role role) newUser,required TResult Function() loggedOut,}) {final _that = this; +switch (_that) { +case NewUserEvent(): +return newUser(_that.user,_that.role);case LoggedOutEvent(): +return loggedOut();} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull({TResult? Function( User user, Role role)? newUser,TResult? Function()? loggedOut,}) {final _that = this; +switch (_that) { +case NewUserEvent() when newUser != null: +return newUser(_that.user,_that.role);case LoggedOutEvent() when loggedOut != null: +return loggedOut();case _: + return null; + +} +} + +} + +/// @nodoc + + +class NewUserEvent implements AuthEvent { + const NewUserEvent(this.user, this.role); + + + final User user; + final Role role; + +/// Create a copy of AuthEvent +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$NewUserEventCopyWith get copyWith => _$NewUserEventCopyWithImpl(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is NewUserEvent&&(identical(other.user, user) || other.user == user)&&(identical(other.role, role) || other.role == role)); +} + + +@override +int get hashCode => Object.hash(runtimeType,user,role); + +@override +String toString() { + return 'AuthEvent.newUser(user: $user, role: $role)'; +} + + +} + +/// @nodoc +abstract mixin class $NewUserEventCopyWith<$Res> implements $AuthEventCopyWith<$Res> { + factory $NewUserEventCopyWith(NewUserEvent value, $Res Function(NewUserEvent) _then) = _$NewUserEventCopyWithImpl; +@useResult +$Res call({ + User user, Role role +}); + + + + +} +/// @nodoc +class _$NewUserEventCopyWithImpl<$Res> + implements $NewUserEventCopyWith<$Res> { + _$NewUserEventCopyWithImpl(this._self, this._then); + + final NewUserEvent _self; + final $Res Function(NewUserEvent) _then; + +/// Create a copy of AuthEvent +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') $Res call({Object? user = null,Object? role = null,}) { + return _then(NewUserEvent( +null == user ? _self.user : user // ignore: cast_nullable_to_non_nullable +as User,null == role ? _self.role : role // ignore: cast_nullable_to_non_nullable +as Role, + )); +} + + +} + +/// @nodoc + + +class LoggedOutEvent implements AuthEvent { + const LoggedOutEvent(); + + + + + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is LoggedOutEvent); +} + + +@override +int get hashCode => runtimeType.hashCode; + +@override +String toString() { + return 'AuthEvent.loggedOut()'; +} + + +} + + + + +/// @nodoc +mixin _$AuthState { + + User? get user; Role? get role; +/// Create a copy of AuthState +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$AuthStateCopyWith get copyWith => _$AuthStateCopyWithImpl(this as AuthState, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is AuthState&&(identical(other.user, user) || other.user == user)&&(identical(other.role, role) || other.role == role)); +} + + +@override +int get hashCode => Object.hash(runtimeType,user,role); + +@override +String toString() { + return 'AuthState(user: $user, role: $role)'; +} + + +} + +/// @nodoc +abstract mixin class $AuthStateCopyWith<$Res> { + factory $AuthStateCopyWith(AuthState value, $Res Function(AuthState) _then) = _$AuthStateCopyWithImpl; +@useResult +$Res call({ + User? user, Role? role +}); + + + + +} +/// @nodoc +class _$AuthStateCopyWithImpl<$Res> + implements $AuthStateCopyWith<$Res> { + _$AuthStateCopyWithImpl(this._self, this._then); + + final AuthState _self; + final $Res Function(AuthState) _then; + +/// Create a copy of AuthState +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? user = freezed,Object? role = freezed,}) { + return _then(_self.copyWith( +user: freezed == user ? _self.user : user // ignore: cast_nullable_to_non_nullable +as User?,role: freezed == role ? _self.role : role // ignore: cast_nullable_to_non_nullable +as Role?, + )); +} + +} + + +/// Adds pattern-matching-related methods to [AuthState]. +extension AuthStatePatterns on AuthState { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _AuthState value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _AuthState() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _AuthState value) $default,){ +final _that = this; +switch (_that) { +case _AuthState(): +return $default(_that);} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _AuthState value)? $default,){ +final _that = this; +switch (_that) { +case _AuthState() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( User? user, Role? role)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _AuthState() when $default != null: +return $default(_that.user,_that.role);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( User? user, Role? role) $default,) {final _that = this; +switch (_that) { +case _AuthState(): +return $default(_that.user,_that.role);} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( User? user, Role? role)? $default,) {final _that = this; +switch (_that) { +case _AuthState() when $default != null: +return $default(_that.user,_that.role);case _: + return null; + +} +} + +} + +/// @nodoc + + +class _AuthState extends AuthState { + const _AuthState({this.user, this.role}): super._(); + + +@override final User? user; +@override final Role? role; + +/// Create a copy of AuthState +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$AuthStateCopyWith<_AuthState> get copyWith => __$AuthStateCopyWithImpl<_AuthState>(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _AuthState&&(identical(other.user, user) || other.user == user)&&(identical(other.role, role) || other.role == role)); +} + + +@override +int get hashCode => Object.hash(runtimeType,user,role); + +@override +String toString() { + return 'AuthState(user: $user, role: $role)'; +} + + +} + +/// @nodoc +abstract mixin class _$AuthStateCopyWith<$Res> implements $AuthStateCopyWith<$Res> { + factory _$AuthStateCopyWith(_AuthState value, $Res Function(_AuthState) _then) = __$AuthStateCopyWithImpl; +@override @useResult +$Res call({ + User? user, Role? role +}); + + + + +} +/// @nodoc +class __$AuthStateCopyWithImpl<$Res> + implements _$AuthStateCopyWith<$Res> { + __$AuthStateCopyWithImpl(this._self, this._then); + + final _AuthState _self; + final $Res Function(_AuthState) _then; + +/// Create a copy of AuthState +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? user = freezed,Object? role = freezed,}) { + return _then(_AuthState( +user: freezed == user ? _self.user : user // ignore: cast_nullable_to_non_nullable +as User?,role: freezed == role ? _self.role : role // ignore: cast_nullable_to_non_nullable +as Role?, + )); +} + + +} + +// dart format on diff --git a/genlatte/genlatte-ui/lib/src/core/core.dart b/genlatte/genlatte-ui/lib/src/core/core.dart new file mode 100644 index 0000000..feddce0 --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/core/core.dart @@ -0,0 +1,8 @@ +// Copyright 2014 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 'app_env.dart'; +export 'auth/auth.dart'; +export 'dependency_injection.dart'; +export 'routing/routing.dart'; diff --git a/genlatte/genlatte-ui/lib/src/core/dependency_injection.dart b/genlatte/genlatte-ui/lib/src/core/dependency_injection.dart new file mode 100644 index 0000000..9e9f438 --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/core/dependency_injection.dart @@ -0,0 +1,116 @@ +// Copyright 2014 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:cloud_firestore/cloud_firestore.dart'; +import 'package:cloud_functions/cloud_functions.dart'; +import 'package:data_layer/data_layer.dart'; +import 'package:data_layer_hive/data_layer_hive.dart'; +import 'package:firebase_analytics/firebase_analytics.dart'; +import 'package:firebase_auth/firebase_auth.dart'; +import 'package:firebase_remote_config/firebase_remote_config.dart'; +import 'package:flutter/foundation.dart' show kIsWeb; +import 'package:genlatte/hive/hive_adapters.dart'; +import 'package:genlatte/src/core/core.dart'; +import 'package:genlatte/src/core/utils/utils.dart'; +import 'package:genlatte/src/data/data.dart'; +import 'package:genlatte/src/data/shared_preferences_repository.dart'; +import 'package:genlatte_data/models.dart'; +import 'package:get_it/get_it.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// Instantiates and registers all dependencies. +Future setUpDependencyInjection({ + required AppEnv appEnv, + required FirebaseFirestore firebaseFirestore, + required FirebaseFunctions firebaseFunctions, + FirebaseAuth? firebaseAuth, + FirebaseAnalytics? firebaseAnalytics, + FirebaseRemoteConfig? firebaseRemoteConfig, +}) async { + GetIt.I.registerSingleton(appEnv); + GetIt.I.registerSingleton( + firebaseAnalytics ?? FirebaseAnalytics.instance, + ); + + final remoteConfig = firebaseRemoteConfig ?? FirebaseRemoteConfig.instance; + GetIt.I.registerSingleton(remoteConfig); + + await remoteConfig.setConfigSettings( + RemoteConfigSettings( + fetchTimeout: const Duration(minutes: 1), + minimumFetchInterval: const Duration(hours: 1), + ), + ); + await remoteConfig.fetchAndActivate(); + final contentGroup = remoteConfig.getString('contentgroup'); + + final analytics = firebaseAnalytics ?? FirebaseAnalytics.instance; + if (!kIsWeb) { + await analytics.setDefaultEventParameters({'content-group': contentGroup}); + } + + GetIt.I.registerSingleton( + firebaseAuth ?? FirebaseAuth.instance, + ); + GetIt.I.registerSingleton( + firebaseFunctions, + ); + GetIt.I.registerSingleton( + FirebaseFunctionsClient( + GetIt.I(), + appEnv: appEnv, + ), + ); + GetIt.I.registerSingleton(firebaseFirestore); + + // This must come before any repositories which use a [HiveSource]. + GetIt.I.registerSingleton(AppHiveInitializer()); + + GetIt.I.registerSingleton(AuthBloc(GetIt.I())); + + GetIt.I.registerSingleton( + AppRouter(authBloc: GetIt.I()), + ); + + GetIt.I.registerSingleton>( + LatteImageBatchRepository(), + ); + final ordersRepository = LatteOrdersRepository( + acceptImage: GetIt.I().acceptImage, + completeOrder: GetIt.I().completeOrder, + generateRevisedImages: + GetIt.I().generateRevisedImages, + rejectImageBatch: GetIt.I().rejectImageBatch, + sendToPrinters: GetIt.I().sendToPrinters, + submitOrder: GetIt.I().submitOrder, + ); + GetIt.I.registerSingleton>( + ordersRepository, + ); + GetIt.I.registerSingleton( + ordersRepository, + ); + + GetIt.I.registerSingleton>( + RecentLatteImagesRepository(), + ); + + GetIt.I.registerSingleton>( + LatteOrdersMetadataRepository(), + ); + GetIt.I.registerSingleton>( + LatteOptionsRepository(), + ); + GetIt.I.registerSingleton>( + BaristaRepository(), + ); + GetIt.I.registerSingleton>( + MachineRepository(), + ); + + final prefs = await SharedPreferences.getInstance(); + GetIt.I.registerSingleton( + SharedPreferencesRepository(prefs), + ); +} diff --git a/genlatte/genlatte-ui/lib/src/core/routing/CONTEXT.md b/genlatte/genlatte-ui/lib/src/core/routing/CONTEXT.md new file mode 100644 index 0000000..482683c --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/core/routing/CONTEXT.md @@ -0,0 +1,24 @@ +# Routing Core + +**Purpose:** +Manages deep linking, navigation, and role-based access control inside the application using `go_router`. + +**Detailed File Overviews:** + +- `routing.dart`: + - **Description**: Barrel export file. + +- `routes.dart`: + - **Description**: Constants map defining the physical string URL routes available to the application. + +- `router.dart`: + - **Description**: Initializes the `GoRouter` instance. + - **Core Logic**: Configures a global `redirect` callback that evaluates every navigation request against the `GoRouterRedirector`, keeping users locked to screens mapped to their Firebase Auth custom claims role. Validates redirection updates dynamically using the `authBloc.stream` to catch role changes or log-outs. + +- `redirection.dart`: + - **Description**: The core Role-Based Access Control logic framework. + - **Core Logic**: Contains a suite of `Redirector` classes (like `NonBaristaAwayFromBaristaHome` and `AnonymousUsersToLogin`) that enforce strict path boundaries preventing users from guessing URLs. + +**Dependencies/Relationships:** +- Depends entirely on `AuthBloc` to determine the active user's roles. +- Consumed globally via standard `context.go()` calls across the app. diff --git a/genlatte/genlatte-ui/lib/src/core/routing/redirection.dart b/genlatte/genlatte-ui/lib/src/core/routing/redirection.dart new file mode 100644 index 0000000..0b35113 --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/core/routing/redirection.dart @@ -0,0 +1,447 @@ +// Copyright 2014 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. + +/// @docImport 'package:genlatte/src/role.dart'; +library; + +import 'package:genlatte/src/core/core.dart'; +import 'package:go_router/go_router.dart'; +import 'package:logging/logging.dart'; + +final _log = Logger('Redirection'); + +/// Type alias for route parameters. +typedef Params = Map; + +/// {@template GoRouterRedirector} +/// Validates that the user's current location within the app is allowed by +/// their authentication state and other details like app health. +/// {@endtemplate} +class GoRouterRedirector { + /// Singleton constructor. + factory GoRouterRedirector() => const GoRouterRedirector._( + [ + AnonymousUsersToLogin(), + AuthenticatedUsersAwayFromLogin(), + NonBaristaAwayFromBaristaHome(), + NonKioskAwayFromKioskHome(), + NonQueueObserverAwayFromQueue(), + NonRecentOrdersObserverAwayFromRecentOrders(), + NonModeratorObserverAwayFromModeration(), + NonModeratorsAwayFromPrinters(), + ], + [ + // globalMaintenanceRoute.path + // globalForceUpgradeRoute.path + ], + ); + + const GoRouterRedirector._(this._redirects, this._doNotLeave); + + final List _redirects; + + /// Forced dead-end paths that, once routed to, cannot be routed away from by + /// any other redirect rules; but instead, only by the undoing of the + /// conditions that led to redirecting here in the first place. + final List _doNotLeave; + + /// Compares the current [RouteState] against the [AuthState] and returns a + /// string to navigate to if required. Returns null if the current + /// [RouteState] and [AuthState] are compatible. + String? redirect({ + required RouteState routeState, + required AuthState authState, + }) { + _log.finest( + 'Considering redirect for "${routeState.path}" with user ' + '${authState.user?.uid} and role ${authState.role}', + ); + if (_doNotLeave.contains(routeState.path)) { + _log.finest( + 'Not navigating away from ${routeState.path} for DO NOT LEAVE', + ); + return null; + } + final current = Uri( + path: routeState.path, + queryParameters: + routeState + .uri + .queryParameters + .isNotEmpty // + ? routeState.uri.queryParameters + : null, + ); + for (final redirect in _redirects) { + if (redirect.predicate(routeState, authState)) { + final newRouteState = redirect.getNewRouteState(routeState, authState); + final uriString = newRouteState.uri.toString(); + + if (uriString == current.toString()) { + _log.warning( + '$redirect attempted to redirect to itself at $uriString. ' + 'This should have been caught earlier!', + ); + continue; + } + + _log.finer('$redirect redirecting from $current to $uriString'); + return uriString; + } else { + _log.finest( + '$redirect declined to redirect away from ${routeState.path}', + ); + } + } + _log.finer('Not redirecting away from ${routeState.path}'); + return null; + } +} + +/// {@template RouteState} +/// Simplified representation of the user's location within the app. Exists to +/// contain an individual routing solution from leaking its logic all across +/// the app's codebase. +/// {@endtemplate} +class RouteState { + /// Instantiates a new [RouteState]. + const RouteState({ + required this.uri, + this.route, + }); + + /// Converts a [GoRouterState] object into the values the rest of our app will + /// care about. + factory RouteState.fromGoRouterState(GoRouterState state) { + assert( + state.fullPath != null, + 'Unexpectedly had null [GoRouterState.fullPath]', + ); + return RouteState( + uri: state.uri, + route: state.topRoute, + ); + } + + /// Builds a GoRouterState value from a given route. + /// Useful for the initial route. + factory RouteState.fromRoute(GoRoute route, {Params? pathParameters}) { + String path = route.path; + if (pathParameters != null) { + for (final key in pathParameters.keys) { + path = path.replaceAll(':$key', pathParameters[key]!); + } + } + return RouteState( + uri: Uri(path: path), + route: route, + ); + } + + /// Fully formed URI of the route. + final Uri uri; + + /// The [GoRoute] that matches the current URI. + final GoRoute? route; + + /// Path of the route. + String get path => uri.path; + + @override + String toString() => 'RouteState(uri: $uri, route.path: ${route?.path})'; +} + +/// Individual utility within a [GoRouterRedirector] to enforce a single rule. +abstract class Redirector { + /// Const constructor. + const Redirector(); + + /// Determines whether this redirection should take place. + bool predicate(RouteState routeState, AuthState authState); + + /// Returns the desired [Uri] to send the user based on current app state. + RouteState getNewRouteState(RouteState routeState, AuthState authState); + + @override + String toString() => '$runtimeType()'; +} + +/// {@template AnonymousUsersToLogin} +/// Sends anonymous users to the login screen. +/// {@endtemplate} +class AnonymousUsersToLogin extends Redirector { + /// {@macro AnonymousUsersToLogin} + const AnonymousUsersToLogin(); + @override + bool predicate( + RouteState routeState, + AuthState authState, + ) => + routeState.path != AppRoutes.loginRoute.path && + (authState.user == null || authState.role == null); + + @override + RouteState getNewRouteState( + RouteState routeState, + AuthState authState, + ) => RouteState( + uri: Uri( + path: AppRoutes.loginRoute.path, + // Save the destination as a query parameter. + queryParameters: {'continue': routeState.uri.toString()}, + ), + route: AppRoutes.loginRoute, + ); +} + +/// {@template AuthenticatedUsersAwayFromLogin} +/// Sends authenticated users away from the login screen. +/// {@endtemplate} +class AuthenticatedUsersAwayFromLogin extends Redirector { + /// {@macro AuthenticatedUsersAwayFromLogin} + const AuthenticatedUsersAwayFromLogin(); + @override + bool predicate( + RouteState routeState, + AuthState authState, + ) => + routeState.path == AppRoutes.loginRoute.path && + authState.user != null && + authState.role != null; + + @override + RouteState getNewRouteState( + RouteState routeState, + AuthState authState, + ) { + final continueUrl = routeState.uri.queryParameters['continue']; + if (continueUrl != null) { + final uri = Uri.tryParse(continueUrl); + if (uri != null && uri.path != AppRoutes.loginRoute.path) { + _log.info('Continue parameter found, continuing to $uri'); + return RouteState(uri: uri); + } + } + + return RouteState.fromRoute( + switch (authState.role) { + null => throw Exception('Programmatic error in redirection system'), + .barista => AppRoutes.baristaHomeRoute, + .moderator => AppRoutes.moderatorHomeRoute, + .kiosk => AppRoutes.kioskHomeRoute, + .queueObserver => AppRoutes.queueRoute, + .recentOrdersObserver => AppRoutes.recentOrdersRoute, + }, + ); + } +} + +/// {@template NonBaristaAwayFromBaristaHome} +/// Sends non-barista users away from the barista home screen. This locks down +/// the barista home screen to only baristas and prevents, for example, kiosk +/// users from guessing URLs and performing Barista-only actions. +/// {@endtemplate} +class NonBaristaAwayFromBaristaHome extends Redirector { + /// {@macro NonBaristaAwayFromBaristaHome} + const NonBaristaAwayFromBaristaHome(); + @override + bool predicate( + RouteState routeState, + AuthState authState, + ) => + routeState.path == AppRoutes.baristaHomeRoute.path && + authState.role != .barista; + + @override + RouteState getNewRouteState( + RouteState routeState, + AuthState authState, + ) => RouteState.fromRoute( + switch (authState.role) { + null => AppRoutes.loginRoute, + .barista => throw Exception( + 'Programmatic error in redirection system. Barista user should not ' + 'have passed predicate for NonBaristaAwayFromBaristaHome', + ), + .moderator => AppRoutes.moderatorHomeRoute, + .kiosk => AppRoutes.kioskHomeRoute, + .queueObserver => AppRoutes.queueRoute, + .recentOrdersObserver => AppRoutes.recentOrdersRoute, + }, + ); +} + +/// {@template NonKioskAwayFromKioskHome} +/// Sends non-kiosk users away from the kiosk home screen. This locks down +/// the kiosk home screen to only kiosks and prevents, for example, the Recent +/// Orders Observer from guessing URLs and performing Kiosk-only actions. +/// {@endtemplate} +class NonKioskAwayFromKioskHome extends Redirector { + /// {@macro NonKioskAwayFromKioskHome} + const NonKioskAwayFromKioskHome(); + @override + bool predicate( + RouteState routeState, + AuthState authState, + ) => + routeState.path == AppRoutes.kioskHomeRoute.path && + authState.role != .kiosk; + + @override + RouteState getNewRouteState( + RouteState routeState, + AuthState authState, + ) => RouteState.fromRoute( + switch (authState.role) { + null => AppRoutes.loginRoute, + .barista => AppRoutes.baristaHomeRoute, + .moderator => AppRoutes.moderatorHomeRoute, + .kiosk => throw Exception( + 'Programmatic error in redirection system. Kiosk user should not ' + 'have passed predicate for NonKioskAwayFromKioskHome', + ), + .queueObserver => AppRoutes.queueRoute, + .recentOrdersObserver => AppRoutes.recentOrdersRoute, + }, + ); +} + +/// {@template NonQueueObserverAwayFromQueue} +/// Sends non-queue observer users away from the queue screen. This locks down +/// the queue screen to only queue observers and prevents... actually not much +/// in this situation since the queue observers will be elevated monitors no one +/// can easily access to hijack. But, still. Here we are. +/// {@endtemplate} +class NonQueueObserverAwayFromQueue extends Redirector { + /// {@macro NonQueueObserverAwayFromQueue} + const NonQueueObserverAwayFromQueue(); + @override + bool predicate( + RouteState routeState, + AuthState authState, + ) => + routeState.path == AppRoutes.queueRoute.path && + authState.role != .queueObserver; + + @override + RouteState getNewRouteState( + RouteState routeState, + AuthState authState, + ) => RouteState.fromRoute( + switch (authState.role) { + null => AppRoutes.loginRoute, + .barista => AppRoutes.baristaHomeRoute, + .moderator => AppRoutes.moderatorHomeRoute, + .kiosk => AppRoutes.kioskHomeRoute, + .queueObserver => throw Exception( + 'Programmatic error in redirection system. Queue observer user should ' + 'not have passed predicate for NonQueueObserverAwayFromQueue', + ), + .recentOrdersObserver => AppRoutes.recentOrdersRoute, + }, + ); +} + +/// {@template NonRecentOrdersObserverAwayFromRecentOrders} +/// Sends non-recent orders observer users away from the recent orders screen. +/// This locks down the recent orders screen to only recent orders observers +/// and prevents someone from hijacking said screen to view any of the other +/// homescreens. +/// {@endtemplate} +class NonRecentOrdersObserverAwayFromRecentOrders extends Redirector { + /// {@macro NonRecentOrdersObserverAwayFromRecentOrders} + const NonRecentOrdersObserverAwayFromRecentOrders(); + @override + bool predicate( + RouteState routeState, + AuthState authState, + ) => + routeState.path == AppRoutes.recentOrdersRoute.path && + authState.role != .recentOrdersObserver; + + @override + RouteState getNewRouteState( + RouteState routeState, + AuthState authState, + ) => RouteState.fromRoute( + switch (authState.role) { + null => AppRoutes.loginRoute, + .barista => AppRoutes.baristaHomeRoute, + .moderator => AppRoutes.moderatorHomeRoute, + .kiosk => AppRoutes.kioskHomeRoute, + .queueObserver => AppRoutes.queueRoute, + .recentOrdersObserver => throw Exception( + 'Programmatic error in redirection system. Recent orders observer ' + 'user should not have passed predicate for ' + 'NonRecentOrdersObserverAwayFromRecentOrders', + ), + }, + ); +} + +/// {@template NonModeratorObserverAwayFromModeration} +/// Sends non-moderator users away from the moderation screen. +/// This locks down the moderation screen to only moderators and prevents +/// someone from hijacking said screen to view any of the other homescreens. +/// {@endtemplate} +class NonModeratorObserverAwayFromModeration extends Redirector { + /// {@macro NonModeratorObserverAwayFromModeration} + const NonModeratorObserverAwayFromModeration(); + @override + bool predicate( + RouteState routeState, + AuthState authState, + ) => + routeState.path == AppRoutes.moderatorHomeRoute.path && + authState.role != .moderator; + + @override + RouteState getNewRouteState( + RouteState routeState, + AuthState authState, + ) => RouteState.fromRoute( + switch (authState.role) { + null => AppRoutes.loginRoute, + .barista => AppRoutes.baristaHomeRoute, + .moderator => throw Exception( + 'Programmatic error in redirection system. Moderator user should not ' + 'have passed predicate for NonModeratorObserverAwayFromModeration', + ), + .kiosk => AppRoutes.kioskHomeRoute, + .queueObserver => AppRoutes.queueRoute, + .recentOrdersObserver => AppRoutes.recentOrdersRoute, + }, + ); +} + +/// Shews non-moderators away from the printers editing view. +class NonModeratorsAwayFromPrinters extends Redirector { + /// {@macro NonModeratorsAwayFromPrinters} + const NonModeratorsAwayFromPrinters(); + @override + bool predicate( + RouteState routeState, + AuthState authState, + ) => + routeState.path == AppRoutes.moderatorPrintersRoute.path && + authState.role != .moderator; + + @override + RouteState getNewRouteState( + RouteState routeState, + AuthState authState, + ) => RouteState.fromRoute( + switch (authState.role) { + null => AppRoutes.loginRoute, + .barista => AppRoutes.baristaHomeRoute, + .moderator => throw Exception( + 'Programmatic error in redirection system. Moderator user should not ' + 'have passed predicate for NonModeratorObserverAwayFromPrinters', + ), + .kiosk => AppRoutes.kioskHomeRoute, + .queueObserver => AppRoutes.queueRoute, + .recentOrdersObserver => AppRoutes.recentOrdersRoute, + }, + ); +} diff --git a/genlatte/genlatte-ui/lib/src/core/routing/router.dart b/genlatte/genlatte-ui/lib/src/core/routing/router.dart new file mode 100644 index 0000000..b04ce4b --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/core/routing/router.dart @@ -0,0 +1,100 @@ +// Copyright 2014 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. + +/// @docImport 'package:genlatte/src/role.dart'; +library; + +import 'dart:async'; + +import 'package:genlatte/src/core/core.dart'; +import 'package:go_router/go_router.dart'; +import 'package:logging/logging.dart'; +import 'package:meta/meta.dart'; + +final _log = Logger('AppRouter'); + +/// [GoRouter] wrapper with logging and redirection logic. +class AppRouter { + /// Instantiates a new [AppRouter]. + AppRouter({required this.authBloc}) { + redirection = GoRouterRedirector(); + + router = GoRouter( + routes: AppRoutes.routes, + initialLocation: AppRoutes.initialRoute.path, + redirect: (context, GoRouterState state) { + lastRouteState = RouteState.fromGoRouterState(state); + final redirection = _redirect(authBloc.state); + if (redirection != null) { + lastRouteState = RouteState( + uri: Uri.parse(redirection), + ); + return redirection; + } + return null; + }, + ); + + authBloc.stream.listen((authState) { + _log.finest( + 'AuthState changed: User.uid: ${authState.user?.uid} :: ' + 'Role: ${authState.role}', + ); + + // Prevent race condition: If GoRouter hasn't parsed the initial URL yet, + // do not fire programmatic redirects. The above GoRouter.redirect + // callback will handle the initial routing pass using authBloc.state. + if (lastRouteState == null) { + _log.fine( + 'GoRouter not yet initialized. Deferring to initial redirect.', + ); + return; + } + + final redirection = _redirect(authState); + if (redirection != null) { + lastRouteState = RouteState( + uri: Uri.parse(redirection), + ); + router.go(redirection); + } + }); + } + + /// {@macro AuthBloc} + final AuthBloc authBloc; + + /// [GoRouter] instance. + late final GoRouter router; + + /// Ye who keeps users from wandering off into the woods. + late final GoRouterRedirector redirection; + + /// Cache of the last known [RouteState]. + RouteState? lastRouteState; + + final _redirections = StreamController.broadcast(); + + /// Emits redirection decisions + @visibleForTesting + Stream get allRedirects => _redirections.stream; + + String? _redirect(AuthState authState) { + final redirect = redirection.redirect( + routeState: + lastRouteState ?? // + RouteState.fromRoute(AppRoutes.initialRoute), + authState: authState, + ); + _redirections.add(redirect); + return redirect; + } + + /// Redirects the user to the options editing screen. + void toOptions() => router.go('/moderator/options'); + + /// Redirects the user to the printers editing screen. If the user is not a + /// moderator, they will immediately bounce back to their own homepage. + void toMachines() => router.go('/moderator/machines'); +} diff --git a/genlatte/genlatte-ui/lib/src/core/routing/routes.dart b/genlatte/genlatte-ui/lib/src/core/routing/routes.dart new file mode 100644 index 0000000..a5c52a6 --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/core/routing/routes.dart @@ -0,0 +1,91 @@ +// Copyright 2014 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:genlatte/src/role.dart'; +import 'package:genlatte/src/screens/barista/barista.dart'; +import 'package:genlatte/src/screens/kiosk/kiosk.dart'; +import 'package:genlatte/src/screens/login/login.dart'; +import 'package:genlatte/src/screens/moderator/moderator.dart'; +import 'package:genlatte/src/screens/queue/queue.dart'; +import 'package:genlatte/src/screens/recent_orders/recent_orders.dart'; +import 'package:go_router/go_router.dart'; + +/// {@template AppRoutes} +/// Container for all possible routes. One version of this must exist per user +/// [Role]. +/// {@endtemplate} +class AppRoutes { + /// Login route. + static final loginRoute = GoRoute( + path: '/login', + name: 'login', + builder: (context, state) => const LoginScreen(), + ); + + /// Barista home route. + static final baristaHomeRoute = GoRoute( + path: '/barista', + name: 'baristaHome', + builder: (context, state) => const BaristaHomeScreen(), + ); + + /// Moderator subscreen to manage printers. + static final moderatorPrintersRoute = GoRoute( + path: '/machines', + name: 'machines', + builder: (context, state) => const MachinesScreen(), + ); + + /// Moderator subscreen to manage options. + static final moderatorOptionsRoute = GoRoute( + path: '/options', + name: 'options', + builder: (context, state) => const OptionsScreen(), + ); + + /// Moderator home route. + static final moderatorHomeRoute = GoRoute( + path: '/moderator', + name: 'moderatorHome', + builder: (context, state) => const ModeratorHomeScreen(), + routes: [ + moderatorPrintersRoute, + moderatorOptionsRoute, + ], + ); + + /// Kiosk home route. + static final kioskHomeRoute = GoRoute( + path: '/kiosk', + name: 'kioskHome', + builder: (context, state) => const KioskHomeScreen(), + ); + + /// Queue home route. + static final queueRoute = GoRoute( + path: '/queue', + name: 'queue', + builder: (context, state) => const QueueHomeScreen(), + ); + + /// Recent orders home route. + static final recentOrdersRoute = GoRoute( + path: '/recentOrders', + name: 'recentOrders', + builder: (context, state) => const RecentOrdersHomeScreen(), + ); + + /// Starting route passed to [GoRouter]. + static final GoRoute initialRoute = loginRoute; + + /// All available routes. + static final List routes = [ + loginRoute, + baristaHomeRoute, + moderatorHomeRoute, + kioskHomeRoute, + queueRoute, + recentOrdersRoute, + ]; +} diff --git a/genlatte/genlatte-ui/lib/src/core/routing/routing.dart b/genlatte/genlatte-ui/lib/src/core/routing/routing.dart new file mode 100644 index 0000000..e14e5a3 --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/core/routing/routing.dart @@ -0,0 +1,7 @@ +// Copyright 2014 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 'redirection.dart'; +export 'router.dart'; +export 'routes.dart'; diff --git a/genlatte/genlatte-ui/lib/src/core/utils/CONTEXT.md b/genlatte/genlatte-ui/lib/src/core/utils/CONTEXT.md new file mode 100644 index 0000000..0b260c3 --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/core/utils/CONTEXT.md @@ -0,0 +1,22 @@ +# Utils Core + +**Purpose:** +Contains miscellaneous shared utility functions used across the `genlatte-ui` application layer. + +**Detailed File Overviews:** + +- `utils.dart`: + - **Description**: Barrel export file. + +- `double_extensions.dart`: + - **Description**: Helper extension functions applied to the `double` primitives for standardized formatting. + +- `firebase_functions_client.dart`: + - **Description**: Helper wrapper for `FirebaseFunctions`. + - **Core Logic**: Contains standardized error catching routines used when calling HTTP-callable Cloud Functions. + +- `position.dart`: + - **Description**: A utility class abstracting `x` and `y` geometric alignments used by visual configuration steps. + +**Dependencies/Relationships:** +- General purpose scope utilized throughout the domain. diff --git a/genlatte/genlatte-ui/lib/src/core/utils/double_extensions.dart b/genlatte/genlatte-ui/lib/src/core/utils/double_extensions.dart new file mode 100644 index 0000000..cdc98f4 --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/core/utils/double_extensions.dart @@ -0,0 +1,67 @@ +// Copyright 2014 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/animation.dart'; + +/// Fancy double extensions. +extension FancyDouble on double { + /// Rounds this double to the given number of decimal places. + /// + /// Maybe there's a better way to do this, but if so, I haven't found it. + double roundTo(int decimalPlaces) => + double.parse(toStringAsFixed(decimalPlaces)); + + /// Divides [range] into a fixed number of [phases] and returns which phase + /// this double fits into. + /// + /// For usage, see `extensions_test.dart`. + /// Companion method to [progressThroughPhase]. + int phaseWithinRange( + int phases, + double range, { + double? spacePerPhase, + Curve curve = Curves.linear, + }) { + // Subtract 1 from `phases` because it is an `arr.length` situation, + // but we are returning an index. + if (this >= range) return phases - 1; + spacePerPhase ??= range / phases; + return (this / spacePerPhase).toInt(); + } + + /// Returns this double's progress through its given phase within a range. + /// + /// Usage: + /// ```dart + /// 0.05.progressThroughPhase(10, 1) => 0.5 + /// 0.1.progressThroughPhase(10, 1) => 1.0 + /// 0.15.progressThroughPhase(10, 1) => 0.5 + /// 0.16.progressThroughPhase(10, 1) => 0.6 + /// 0.26.progressThroughPhase(10, 1) => 0.6 + /// ``` + /// + /// Companion method to [phaseWithinRange]. + /// + /// If [phase] is provided, it will be used instead of calling + /// [phaseWithinRange]. Use this when your calling code also needed to know + /// the specific phase number and so already called [phaseWithinRange]. + double progressThroughPhase( + int phases, + double range, { + int? phase, + Curve curve = Curves.linear, + }) { + final spacePerPhase = range / phases; + final calculatedPhase = + phase ?? + phaseWithinRange( + phases, + range, + spacePerPhase: spacePerPhase, + ); + return curve.transform( + ((this - (spacePerPhase * calculatedPhase)) / spacePerPhase).clamp(0, 1), + ); + } +} diff --git a/genlatte/genlatte-ui/lib/src/core/utils/firebase_functions_client.dart b/genlatte/genlatte-ui/lib/src/core/utils/firebase_functions_client.dart new file mode 100644 index 0000000..4c2e592 --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/core/utils/firebase_functions_client.dart @@ -0,0 +1,132 @@ +// Copyright 2014 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:cloud_functions/cloud_functions.dart'; +import 'package:genlatte/src/core/core.dart'; +import 'package:logging/logging.dart'; + +final _logger = Logger('FirebaseFunctionsClient'); + +/// A utility class for calling Firebase Functions with retry logic. +class FirebaseFunctionsClient { + /// Creates a new [FirebaseFunctionsClient]. + FirebaseFunctionsClient(this._firebaseFunctions, {required this.appEnv}); + + /// The Firebase Functions instance to use. + final FirebaseFunctions _firebaseFunctions; + + /// The active application environment. + final AppEnv appEnv; + + /// Calls 'sendToPrinters' to send an order to the printers. + Future sendToPrinters({ + required String imagePath, + required String machineName, + }) async { + await _callWithRetry( + functionName: 'sendToPrinter${appEnv.name}', + parameters: { + 'imagePath': imagePath, + 'machineName': machineName, + }, + ); + } + + /// Calls 'selectImagestaging' to accept an image. + Future acceptImage({ + required String imageBatchId, + required String imageIndex, + }) async { + await _callWithRetry( + functionName: 'selectImage${appEnv.name}', + parameters: { + 'imageBatchId': imageBatchId, + 'imageIndex': imageIndex, + }, + ); + } + + /// Calls 'completeOrder{env}' conclude the life cycle of an order. + Future completeOrder({ + required String orderId, + required String baristaId, + }) async { + await _callWithRetry( + functionName: 'completeOrder${appEnv.name}', + parameters: { + 'orderId': orderId, + 'baristaId': baristaId, + }, + ); + } + + /// Calls 'generateRevisedImagesstaging' to revise an image batch. + Future generateRevisedImages({ + required String imageBatchId, + required String imageIndex, + required Map answers, + }) async { + await _callWithRetry( + functionName: 'generateRevisedImages${appEnv.name}', + parameters: { + 'imageBatchId': imageBatchId, + 'imageIndex': imageIndex, + 'answers': answers, + }, + ); + } + + /// Calls 'rejectRevisionstaging' to reject an image batch. + Future rejectImageBatch(String imageBatchId) async { + await _callWithRetry( + functionName: 'rejectRevision${appEnv.name}', + parameters: { + 'imageBatchId': imageBatchId, + }, + ); + } + + /// Calls 'submitOrderstaging' to submit an order. + Future submitOrder(String orderId) async { + await _callWithRetry( + functionName: 'submitOrder${appEnv.name}', + parameters: { + 'orderId': orderId, + }, + ); + } + + Future _callWithRetry({ + required String functionName, + required Map parameters, + int maxRetries = 3, + }) async { + int attempt = 0; + while (true) { + try { + _logger.info( + 'Calling Firebase Function $functionName with ' + 'parameters: $parameters', + ); + final callable = _firebaseFunctions.httpsCallable(functionName); + await callable.call(parameters); + return; + } catch (e) { + attempt++; + if (attempt >= maxRetries) { + _logger.severe( + 'Failed to call Firebase Function $functionName for final time.', + e, + ); + rethrow; + } + _logger.warning( + 'Failed to call Firebase Function $functionName. Retrying...', + e, + ); + await Future.delayed(Duration(milliseconds: 500 * attempt)); + } + } + } +} diff --git a/genlatte/genlatte-ui/lib/src/core/utils/position.dart b/genlatte/genlatte-ui/lib/src/core/utils/position.dart new file mode 100644 index 0000000..db74050 --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/core/utils/position.dart @@ -0,0 +1,169 @@ +// Copyright 2014 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 class that represents the position of a widget in a 2D space. +class Position { + /// Creates a new [Position] instance. + const Position({ + this.top, + this.bottom, + this.left, + this.right, + this.width, + this.height, + this.rotation, + this.transform, + this.transformAlignment, + }); + + /// The distance from the top of the parent widget to the top of this widget. + final double? top; + + /// The distance from the bottom of the parent widget to the bottom of this + /// widget. + final double? bottom; + + /// The distance from the left of the parent widget to the left of this + /// widget. + final double? left; + + /// The distance from the right of the parent widget to the right of this + /// widget. + final double? right; + + /// The width of this widget. + final double? width; + + /// The height of this widget. + final double? height; + + /// The rotation of this widget. + final double? rotation; + + /// The transform of this widget. + final Matrix4? transform; + + /// The alignment of the transform. + final Alignment? transformAlignment; + + /// Creates a new [Position] instance with the same values as this instance, + /// but with the given values replaced. + Position copyWith({ + double? top, + double? bottom, + double? left, + double? right, + double? width, + double? height, + double? rotation, + Matrix4? transform, + Alignment? transformAlignment, + }) => Position( + top: top ?? this.top, + bottom: bottom ?? this.bottom, + left: left ?? this.left, + right: right ?? this.right, + width: width ?? this.width, + height: height ?? this.height, + rotation: rotation ?? this.rotation, + transform: transform ?? this.transform, + transformAlignment: transformAlignment ?? this.transformAlignment, + ); + + /// Creates a new [Position] instance shifted in the various directions. + Position shift({ + double? top, + double? bottom, + double? left, + double? right, + }) => Position( + top: top != null ? (this.top ?? 0) + top : this.top, + bottom: bottom != null ? (this.bottom ?? 0) + bottom : this.bottom, + left: left != null ? (this.left ?? 0) + left : this.left, + right: right != null ? (this.right ?? 0) + right : this.right, + width: width, + height: height, + rotation: rotation, + transform: transform, + transformAlignment: transformAlignment, + ); + + /// Converts this [Position] to an [AnimatedPositioned] widget. + AnimatedPositioned toAnimatedWidget({ + required Widget child, + Key? key, + Curve? curve, + Duration? duration, + }) { + // ignore: no_leading_underscores_for_local_identifiers + Widget _child = child; + if (rotation != null) { + _child = Transform.rotate( + angle: rotation!, + child: _child, + ); + } + return AnimatedPositioned( + duration: duration ?? const Duration(milliseconds: 100), + curve: curve ?? Curves.easeOut, + top: top, + bottom: bottom, + left: left, + right: right, + width: width, + height: height, + key: key, + child: child, + ); + } + + /// Converts this [Position] to a [Positioned] widget. + Positioned toWidget({required Widget child, Key? key}) { + // ignore: no_leading_underscores_for_local_identifiers + Widget _child = child; + if (transform != null) { + _child = Transform( + transform: transform!, + alignment: transformAlignment ?? Alignment.topLeft, + child: _child, + ); + } + if (rotation != null) { + _child = Transform.rotate( + angle: rotation!, + child: _child, + ); + } + return Positioned( + top: top, + bottom: bottom, + left: left, + right: right, + width: width, + height: height, + key: key, + child: _child, + ); + } + + /// Converts this [Position] to a [BoxConstraints] widget. + BoxConstraints toBoxConstraints() => BoxConstraints.tightFor( + height: height, + width: width, + ); + + /// Converts this [Position] to an [Offset] widget. + Offset toOffset() => Offset( + height != null ? -height! : 0, + width != null ? width! : 0, + ); + + @override + String toString() => + 'Position(left: $left, top: $top, right: $right, bottom: $bottom, ' + 'width: $width, height: $height, rotation: $rotation, transform: ' + '$transform, transformAlignment: $transformAlignment)'; +} diff --git a/genlatte/genlatte-ui/lib/src/core/utils/utils.dart b/genlatte/genlatte-ui/lib/src/core/utils/utils.dart new file mode 100644 index 0000000..c5f1460 --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/core/utils/utils.dart @@ -0,0 +1,7 @@ +// Copyright 2014 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 'double_extensions.dart'; +export 'firebase_functions_client.dart'; +export 'position.dart'; diff --git a/genlatte/genlatte-ui/lib/src/data/CONTEXT.md b/genlatte/genlatte-ui/lib/src/data/CONTEXT.md new file mode 100644 index 0000000..6974026 --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/data/CONTEXT.md @@ -0,0 +1,24 @@ +# Data Repositories + +**Purpose:** +Provides the "Repository Pattern" abstraction layer, exposing pure Data Models to the application's BLoC state managers while hiding external data communication implementations (like Firestore). + +**Detailed File Overviews:** + +- `data.dart`: + - **Description**: Barrel export file. + +- `latte_orders_repository.dart` / `recent_latte_images_repository.dart` / etc: + - **Description**: Domain-specific repositories. + - **Core Logic**: Wraps methods exposed by `sources/firestore_source.dart` and strictly casts payloads to/from the data structures enforced by `genlatte_data`. They often expose `Stream>` for reactive UI binding. + +- `filters.dart`: + - **Description**: Filtering logic primitives. + - **Core Logic**: Mathematical constraints passed from the Repositories down to the Sources to instruct the database to only yield specific documents (e.g. `Filter.whereEquals`). + +- `shared_preferences_repository.dart`: + - **Description**: Local storage layer. + - **Core Logic**: Wraps standard SharedPreferences for local, offline state persistence (like storing the physical hardware configurations for local Queue displays). + +**Dependencies/Relationships:** +- Serves as the intermediary between `genlatte-ui/lib/src/screens/` BLoCs and `genlatte-ui/lib/src/sources/`. diff --git a/genlatte/genlatte-ui/lib/src/data/barista_repository.dart b/genlatte/genlatte-ui/lib/src/data/barista_repository.dart new file mode 100644 index 0000000..32c6f46 --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/data/barista_repository.dart @@ -0,0 +1,31 @@ +// Copyright 2014 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:cloud_firestore/cloud_firestore.dart' show FirebaseFirestore; +import 'package:data_layer/data_layer.dart'; +import 'package:data_layer_hive/data_layer_hive.dart'; +import 'package:genlatte/src/sources/firestore_source.dart'; +import 'package:genlatte_data/models.dart'; +import 'package:get_it/get_it.dart'; + +/// Repository for [Barista]. +class BaristaRepository extends Repository { + /// Creates a new [BaristaRepository]. + BaristaRepository() + : super( + SourceList( + bindings: Barista.bindings, + sources: >[ + HiveSource( + hiveInit: GetIt.I().ready, + bindings: Barista.bindings, + ), + FirestoreSource( + GetIt.I(), + bindings: Barista.bindings, + ), + ], + ), + ); +} diff --git a/genlatte/genlatte-ui/lib/src/data/data.dart b/genlatte/genlatte-ui/lib/src/data/data.dart new file mode 100644 index 0000000..4851d19 --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/data/data.dart @@ -0,0 +1,13 @@ +// Copyright 2014 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 'barista_repository.dart'; +export 'filters.dart'; +export 'latte_images_repository.dart'; +export 'latte_options_repository.dart'; +export 'latte_orders_metadata_repository.dart'; +export 'latte_orders_repository.dart'; +export 'machines_repository.dart'; +export 'questions_repository.dart'; +export 'recent_latte_images_repository.dart'; diff --git a/genlatte/genlatte-ui/lib/src/data/filters.dart b/genlatte/genlatte-ui/lib/src/data/filters.dart new file mode 100644 index 0000000..53321b9 --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/data/filters.dart @@ -0,0 +1,176 @@ +// Copyright 2014 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. + +/// @docImport 'package:genlatte_data/models.dart'; +library; + +import 'package:cloud_firestore/cloud_firestore.dart' hide Filter, Order; +import 'package:data_layer/data_layer.dart'; +import 'package:genlatte/src/sources/firestore_filters.dart'; +import 'package:genlatte_data/models.dart'; + +/// {@template LatteOrderMetadataBrewQueue} +/// Specifies LatteOrderMetadata documents that should show up in the barista's +/// queue to for brewing. These are moderated orders ready to be fulfilled. +/// {@endtemplate} +class LatteOrderMetadataBrewQueue extends FirestoreFilter { + /// {@macro LatteOrderMetadataBrewQueue} + const LatteOrderMetadataBrewQueue(); + + @override + Query apply(Query query) { + return query + .where( + 'status', + whereIn: [ + LatteOrderStatus.validated.name, + LatteOrderStatus.inProgress.name, + ], + ) + .where( + 'orderSubmittedTime', + isGreaterThan: Timestamp.fromDate( + DateTime.now().toUtc().subtract( + const Duration(hours: 8), + ), + ), + ) + // Note: there's no need to worry about sorting this on "moderation + // time", which a) isn't tracked, and b) should result in the same + // ordering as tracking on orderSubmittedTime anyways. + .orderBy('orderSubmittedTime', descending: true); + } + + @override + CacheKey get cacheKey => 'brew_queue'; + + @override + Json toJson() => { + 'status': { + 'whereIn': [ + LatteOrderStatus.validated.name, + LatteOrderStatus.inProgress.name, + ], + }, + 'orderSubmittedTime': { + 'isGreaterThan': DateTime.now() + .toUtc() + .subtract(const Duration(hours: 8)) + .toIso8601String(), + }, + }; + + @override + Params toParams() => toJson(); +} + +/// {@template LatteOrderMetadataBoardQueue} +/// Specifies LatteOrderMetadata documents that should show up +/// on the public queue board. +/// {@endtemplate} +class LatteOrderMetadataBoardQueue extends FirestoreFilter { + /// {@macro LatteOrderMetadataBoardQueue} + const LatteOrderMetadataBoardQueue(); + + @override + Query apply(Query query) { + return query + .where( + 'status', + whereIn: [ + LatteOrderStatus.submitted.name, + LatteOrderStatus.validated.name, + LatteOrderStatus.inProgress.name, + LatteOrderStatus.completed.name, + ], + ) + // TODO(filiph): also filter on sharding if possible + .orderBy('orderSubmittedTime', descending: true); + } + + @override + CacheKey get cacheKey => 'board_queue'; + + @override + Json toJson() => { + 'status': { + 'whereIn': [ + LatteOrderStatus.submitted.name, + LatteOrderStatus.validated.name, + LatteOrderStatus.inProgress.name, + LatteOrderStatus.completed.name, + ], + }, + }; + + @override + Params toParams() => toJson(); +} + +/// {@template LatteOrderMetadataModerationQueue} +/// Specifies LatteOrderMetadata documents that should show up in the barista's +/// queue to for moderation. Once moderated, these orders will get in the back +/// of the brew queue. +/// {@endtemplate} +class LatteOrderMetadataModerationQueue extends FirestoreFilter { + /// {@macro LatteOrderMetadataModerationQueue} + const LatteOrderMetadataModerationQueue(); + + @override + Query apply(Query query) { + return query + .where( + 'status', + whereIn: [ + LatteOrderStatus.submitted.name, + LatteOrderStatus.validated.name, + LatteOrderStatus.inProgress.name, + ], + ) + .where( + 'orderSubmittedTime', + isGreaterThan: Timestamp.fromDate( + DateTime.now().toUtc().subtract( + const Duration(hours: 8), + ), + ), + ) + .orderBy('orderSubmittedTime', descending: true); + } + + @override + CacheKey get cacheKey => 'moderation_queue'; + + @override + Json toJson() => { + 'status': { + 'whereIn': [ + LatteOrderStatus.submitted.name, + LatteOrderStatus.validated.name, + LatteOrderStatus.inProgress.name, + ], + }, + 'orderSubmittedTime': { + 'isGreaterThan': DateTime.now() + .toUtc() + .subtract(const Duration(hours: 8)) + .toIso8601String(), + }, + }; + + @override + Params toParams() => toJson(); +} + +/// Synthetic filter used to store the logged-in Barista. +class ActiveBaristaFilter extends Filter { + @override + CacheKey get cacheKey => 'active_baristas'; + + @override + Json toJson() => {'active': true}; + + @override + Params toParams() => toJson(); +} diff --git a/genlatte/genlatte-ui/lib/src/data/latte_images_repository.dart b/genlatte/genlatte-ui/lib/src/data/latte_images_repository.dart new file mode 100644 index 0000000..253506f --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/data/latte_images_repository.dart @@ -0,0 +1,29 @@ +// Copyright 2014 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:cloud_firestore/cloud_firestore.dart'; +import 'package:data_layer/data_layer.dart'; +import 'package:genlatte/src/sources/firestore_source.dart'; +import 'package:genlatte_data/models.dart'; +import 'package:get_it/get_it.dart'; + +/// Repository for managing [LatteImageBatch]s. +class LatteImageBatchRepository extends Repository { + /// Instantiates a [LatteImageBatchRepository]. + LatteImageBatchRepository() + : super( + SourceList( + sources: [ + LocalMemorySource( + bindings: LatteImageBatch.bindings, + ), + FirestoreSource( + GetIt.I(), + bindings: LatteImageBatch.bindings, + ), + ], + bindings: LatteImageBatch.bindings, + ), + ); +} diff --git a/genlatte/genlatte-ui/lib/src/data/latte_options_repository.dart b/genlatte/genlatte-ui/lib/src/data/latte_options_repository.dart new file mode 100644 index 0000000..f5b1d20 --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/data/latte_options_repository.dart @@ -0,0 +1,29 @@ +// Copyright 2014 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:cloud_firestore/cloud_firestore.dart' hide Source; +import 'package:data_layer/data_layer.dart'; +import 'package:genlatte/src/sources/firestore_source.dart'; +import 'package:genlatte_data/models.dart'; +import 'package:get_it/get_it.dart'; + +/// Repository for [LatteOptions]. +class LatteOptionsRepository extends Repository { + /// {@macro LatteOptionsRepository} + LatteOptionsRepository() + : super( + SourceList( + sources: >[ + LocalMemorySource( + bindings: LatteOptions.bindings, + ), + FirestoreSource( + GetIt.I(), + bindings: LatteOptions.bindings, + ), + ], + bindings: LatteOptions.bindings, + ), + ); +} diff --git a/genlatte/genlatte-ui/lib/src/data/latte_orders_metadata_repository.dart b/genlatte/genlatte-ui/lib/src/data/latte_orders_metadata_repository.dart new file mode 100644 index 0000000..9895302 --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/data/latte_orders_metadata_repository.dart @@ -0,0 +1,31 @@ +// Copyright 2014 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:cloud_firestore/cloud_firestore.dart' show FirebaseFirestore; +import 'package:data_layer/data_layer.dart'; +import 'package:data_layer_hive/data_layer_hive.dart'; +import 'package:genlatte/src/sources/firestore_source.dart'; +import 'package:genlatte_data/models.dart'; +import 'package:get_it/get_it.dart'; + +/// Repository for [LatteOrderMetadata]. +class LatteOrdersMetadataRepository extends Repository { + /// Creates a new [LatteOrdersMetadataRepository]. + LatteOrdersMetadataRepository() + : super( + SourceList( + bindings: LatteOrderMetadata.bindings, + sources: >[ + HiveSource( + hiveInit: GetIt.I().ready, + bindings: LatteOrderMetadata.bindings, + ), + FirestoreSource( + GetIt.I(), + bindings: LatteOrderMetadata.bindings, + ), + ], + ), + ); +} diff --git a/genlatte/genlatte-ui/lib/src/data/latte_orders_repository.dart b/genlatte/genlatte-ui/lib/src/data/latte_orders_repository.dart new file mode 100644 index 0000000..d46fef0 --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/data/latte_orders_repository.dart @@ -0,0 +1,183 @@ +// Copyright 2014 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:cloud_firestore/cloud_firestore.dart' show FirebaseFirestore; +import 'package:data_layer/data_layer.dart'; +import 'package:data_layer_hive/data_layer_hive.dart'; +import 'package:genlatte/src/sources/firestore_source.dart'; +import 'package:genlatte_data/models.dart'; +import 'package:get_it/get_it.dart'; + +/// {@template GenerateRevisedImages} +/// Signature to answer Gemini's questions and generate revised images. +/// {@endtemplate} +typedef GenerateRevisedImages = + Future Function({ + required String imageBatchId, + required String imageIndex, // {image0, image1, image2, or image3} + required Map answers, + }); + +/// {@template RejectImageBatch} +/// Signature to reject a revised image batch and return to its parent. +/// {@endtemplate} +typedef RejectImageBatch = Future Function(String imageBatchId); + +/// {@template AcceptImage} +/// Signature to accept an image. +/// {@endtemplate} +typedef AcceptImage = + Future Function({ + required String imageBatchId, + required String imageIndex, + }); + +/// {@template SubmitOrder} +/// Signature to advance an order to the "submitted" status. +/// {@endtemplate} +typedef SubmitOrder = Future Function(String orderId); + +/// {@template SendToPrinters} +/// Signature to send an image to the printers. +/// {@endtemplate} +typedef SendToPrinters = + Future Function({ + required String imagePath, + required String machineName, + }); + +/// {@template CompleteOrder} +/// Signature to complete an order. +/// {@endtemplate} +typedef CompleteOrder = + Future Function({ + required String orderId, + required String baristaId, + }); + +/// Repository for [LatteOrder]. +class LatteOrdersRepository extends Repository { + /// Creates a new [LatteOrdersRepository]. + LatteOrdersRepository({ + required AcceptImage acceptImage, + required CompleteOrder completeOrder, + required GenerateRevisedImages generateRevisedImages, + required RejectImageBatch rejectImageBatch, + required SendToPrinters sendToPrinters, + required SubmitOrder submitOrder, + }) : _acceptImage = acceptImage, + _completeOrder = completeOrder, + _generateRevisedImages = generateRevisedImages, + _rejectImageBatch = rejectImageBatch, + _sendToPrinters = sendToPrinters, + _submitOrder = submitOrder, + super( + SourceList( + bindings: LatteOrder.bindings, + sources: >[ + HiveSource( + hiveInit: GetIt.I().ready, + bindings: LatteOrder.bindings, + ), + FirestoreSource( + GetIt.I(), + bindings: LatteOrder.bindings, + ), + ], + ), + ); + + /// {@macro AcceptImage} + Future acceptImage({ + required String imageBatchId, + required String imageIndex, + }) => _acceptImage(imageBatchId: imageBatchId, imageIndex: imageIndex); + final AcceptImage _acceptImage; + + /// {@macro CompleteOrder} + Future completeOrder({ + required String orderId, + required String baristaId, + }) => _completeOrder(orderId: orderId, baristaId: baristaId); + final CompleteOrder _completeOrder; + + /// {@macro GenerateRevisedImages} + Future generateRevisedImages({ + required String imageBatchId, + required String imageIndex, + required Map answers, + }) => _generateRevisedImages( + imageBatchId: imageBatchId, + imageIndex: imageIndex, + answers: answers, + ); + final GenerateRevisedImages _generateRevisedImages; + + /// {@macro RejectImageBatch} + Future rejectImageBatch(String imageBatchId) => + _rejectImageBatch(imageBatchId); + final RejectImageBatch _rejectImageBatch; + + /// {@macro SendToPrinter} + Future sendToPrinters({ + required String imagePath, + required String machineName, + }) => _sendToPrinters(imagePath: imagePath, machineName: machineName); + final SendToPrinters _sendToPrinters; + + /// {@macro SubmitOrder} + Future submitOrder(String orderId) => _submitOrder(orderId); + final SubmitOrder _submitOrder; + + /// Converts a list of [LatteOrderMetadata] into a list of [Latte] objects. + /// + /// This method fetches the corresponding [LatteOrder] for each + /// [LatteOrderMetadata] and bundles them into the container object, [Latte]. + /// + /// Throws a [DataException] if any [LatteOrderMetadata] objects lack a + /// corresponding [LatteOrder]. This should be caught by calling code. + Future> toLattes(List metadatas) async { + final metadataMap = metadatas.toIdsMap(); + + final (orders, missingIds) = await getByIds( + metadataMap.keys.toSet(), + // [RequestType.global] (the default value) is fine for this because + // orders no longer change once they are on the Barista's screen; so + // fetching any locally cached records is guaranteed to be fine. + ); + + if (missingIds.isNotEmpty) { + throw DataException( + 'LatteMetaData Ids $missingIds lacked corresponding LatteOrders', + ); + } + + final lattes = []; + + // It is important to loop over [orders] as returned by [getByIds] and not + // over [event.metadatas] because any records lost (as represented by + // [missingIds]) will naturally be omitted in this way. This allows us to + // guarantee that [metadataMap[order.id!]] below will not be null. + for (final order in orders) { + final metadata = metadataMap[order.id!]!; + lattes.add(Latte(order: order, metadata: metadata)); + } + + return lattes; + } +} + +/// {@template DataException} +/// Exception thrown when data is corrupted and cannot be processed. +/// {@endtemplate} +class DataException implements Exception { + /// {@macro DataException} + DataException(this.message); + + /// Description of the corrupted data. + final String message; + + @override + String toString() => 'DataException($message)'; +} diff --git a/genlatte/genlatte-ui/lib/src/data/machines_repository.dart b/genlatte/genlatte-ui/lib/src/data/machines_repository.dart new file mode 100644 index 0000000..2ae2781 --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/data/machines_repository.dart @@ -0,0 +1,52 @@ +// Copyright 2014 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:cloud_firestore/cloud_firestore.dart' + show FirebaseFirestore, Query; +import 'package:data_layer/data_layer.dart'; +import 'package:data_layer_hive/data_layer_hive.dart'; +import 'package:genlatte/src/sources/firestore_filters.dart'; +import 'package:genlatte/src/sources/firestore_source.dart'; +import 'package:genlatte_data/models.dart'; +import 'package:get_it/get_it.dart'; + +/// Repository for [Machine]. +class MachineRepository extends Repository { + /// Creates a new [MachineRepository]. + MachineRepository() + : super( + SourceList( + bindings: Machine.bindings, + sources: >[ + HiveSource( + hiveInit: GetIt.I().ready, + bindings: Machine.bindings, + ), + FirestoreSource( + GetIt.I(), + bindings: Machine.bindings, + ), + ], + ), + ); +} + +/// Filter for active machines. +class ActiveMachinesFilter extends FirestoreFilter { + /// Instantiates a new [ActiveMachinesFilter]. + const ActiveMachinesFilter(); + + @override + Query apply(Query query) => + query.where('isActive', isEqualTo: true); + + @override + CacheKey get cacheKey => 'active_machines'; + + @override + Json toJson() => {'type': 'active_machines'}; + + @override + Params toParams() => {'isActive': 'true'}; +} diff --git a/genlatte/genlatte-ui/lib/src/data/questions_repository.dart b/genlatte/genlatte-ui/lib/src/data/questions_repository.dart new file mode 100644 index 0000000..f5d477d --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/data/questions_repository.dart @@ -0,0 +1,58 @@ +// Copyright 2014 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:data_layer/data_layer.dart'; +// import 'package:genlatte/src/data/data.dart'; +// import 'package:genlatte_data/models.dart'; + +// /// Repository for managing [Question]s. +// class QuestionsRepository extends Repository { +// /// Instantiates a [QuestionsRepository]. +// QuestionsRepository() +// : super( +// SourceList( +// sources: [ +// LocalMemorySource( +// bindings: Question.bindings, +// ), +// ], +// bindings: Question.bindings, +// ), +// ) { +// setItems( +// const [ +// Question.slider( +// id: 'abc', +// body: 'How hot should this be?', +// minValueLabel: 'Cold', +// maxValueLabel: 'Hot', +// ), +// Question.multipleChoice( +// id: 'xyz', +// body: 'What time of day is it?', +// options: [ +// 'Morning', +// 'Afternoon', +// 'Evening', +// 'Night', +// ], +// ), +// Question.text( +// id: '123', +// body: 'How much do you like pizza?', +// helpText: 'A lot, not so much, or somewhere in between?', +// ), +// Question.valueShiftSlider( +// id: '234', +// body: 'More, fewer, or the same amount of trees?', +// minValueLabel: 'Fewer', +// maxValueLabel: 'More', +// ), +// ], +// RequestDetails.read( +// filter: const QuestionForLatteImage(latteImageId: '1'), +// ), +// ).ignore(); +// } +// } diff --git a/genlatte/genlatte-ui/lib/src/data/recent_latte_images_repository.dart b/genlatte/genlatte-ui/lib/src/data/recent_latte_images_repository.dart new file mode 100644 index 0000000..6505919 --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/data/recent_latte_images_repository.dart @@ -0,0 +1,59 @@ +// Copyright 2014 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:math' show Random; + +import 'package:cloud_firestore/cloud_firestore.dart' hide Source; +import 'package:data_layer/data_layer.dart'; +import 'package:genlatte/src/sources/firestore_source.dart'; +import 'package:genlatte_data/models.dart'; +import 'package:get_it/get_it.dart'; +import 'package:logging/logging.dart'; + +/// Repository for [LatteImage] that is used to fetch recent latte images. +class RecentLatteImagesRepository extends Repository { + /// Instantiates the [RecentLatteImagesRepository]. + RecentLatteImagesRepository() + : super( + SourceList( + bindings: RecentLatteImage.recentBindings, + sources: >[ + FirestoreSource( + GetIt.I(), + bindings: RecentLatteImage.recentBindings, + onCreateServerTimestampFields: ['createdAt'], + ), + ], + ), + ); + + final _log = Logger('RecentLatteImagesRepository'); + + /// Fake method to be deleted. + // TODO(craiglabenz): Delete this and everything around its call site once + // the recent images screen is considered 100% done. + Future addRandomRecentImage() async { + final batchRepository = GetIt.I>(); + final batches = await batchRepository.getItems(); + final randomIndex = Random().nextInt(batches.length); + final randomBatch = batches[randomIndex]; + _log.info('Random batch: ${randomBatch.id}'); + final imageIndex = Random().nextInt(4); + final randomImage = randomBatch.images.toList()[imageIndex]; + + final order = await GetIt.I>().getById( + randomBatch.orderId, + ); + + final recentImage = RecentLatteImage( + imageUrl: randomImage!.imageUrl, + prompt: randomImage.prompt, + description: randomImage.description, + createdAt: DateTime.now(), + happyPlace: order!.happyPlace!, + name: order.name!, + ); + await setItem(recentImage); + } +} diff --git a/genlatte/genlatte-ui/lib/src/data/shared_preferences_repository.dart b/genlatte/genlatte-ui/lib/src/data/shared_preferences_repository.dart new file mode 100644 index 0000000..e3301ee --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/data/shared_preferences_repository.dart @@ -0,0 +1,31 @@ +// Copyright 2014 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:math'; + +import 'package:logging/logging.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// Repository for local settings. Backed by [SharedPreferences] +/// and not mirrored on server. +class SharedPreferencesRepository { + /// Creates a shared preferences repository. + const SharedPreferencesRepository(this._prefs); + + static final Logger _logger = Logger('$SharedPreferencesRepository'); + + final SharedPreferences _prefs; + + /// Reads a value from persistent storage, throwing an exception if it's not a + /// String. + String? getString(String key) => _prefs.getString(key); + + /// Saves a string [value] to persistent storage in the background. + Future setString(String key, String value) { + _logger.fine( + 'Setting $key to "${value.substring(0, min(200, value.length))}"', + ); + return _prefs.setString(key, value); + } +} diff --git a/genlatte/genlatte-ui/lib/src/role.dart b/genlatte/genlatte-ui/lib/src/role.dart new file mode 100644 index 0000000..1224ced --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/role.dart @@ -0,0 +1,42 @@ +// Copyright 2014 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. + +/// @docImport 'package:genlatte/src/core/routing/router.dart'; +library; + +/// Roles that users can have in the app. The given role determines which +/// [AppRouter] is instantiated which in turn completely determines what app +/// experience is provided to the user. +enum Role { + /// Users who manage the queue and approve / reject user submissions. + barista, + + /// Users who can approve / reject user submissions. + moderator, + + /// Users who just need some caffeine. + kiosk, + + /// Read-only "users" who display the queue of upcoming and in-progress drinks + queueObserver, + + /// Read-only "users" who show floating bubbles of recent drinks for selfies + /// and other hi-jinks. + recentOrdersObserver; + + /// True if this role is [.barista]. + bool get isBarista => this == .barista; + + /// True if this role is [.moderator]. + bool get isModerator => this == .moderator; + + /// True if this role is [.kiosk]. + bool get isKiosk => this == .kiosk; + + /// True if this role is [.queueObserver]. + bool get isQueueObserver => this == .queueObserver; + + /// True if this role is [.recentOrdersObserver]. + bool get isRecentOrdersObserver => this == .recentOrdersObserver; +} diff --git a/genlatte/genlatte-ui/lib/src/screens/CONTEXT.md b/genlatte/genlatte-ui/lib/src/screens/CONTEXT.md new file mode 100644 index 0000000..2ea6d25 --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/screens/CONTEXT.md @@ -0,0 +1,11 @@ +# Screens Features Sub-Tree + +**Purpose:** +The primary Presentation Layer source code directory encompassing every visible view inside the `genlatte-ui` package, broken out by independent user personas or global features. + +**Implementation Details:** +- **Navigation**: Each root subfolder inside (`app`, `kiosk`, `barista`, `login`, `queue`, `recent_orders`, `moderator`) serves as a distinct monolithic module backing a major user journey. +- **Architectural Uniformity**: Inside each of those subfolders, you will consistently find a structure further dividing logic into `home/` (BLoC + Views), `util/` (helpers), and `widgets/` (local view components). + +**Dependencies:** +- These view layers bind to data by calling Repositories existing in `genlatte-ui/lib/src/data/`. diff --git a/genlatte/genlatte-ui/lib/src/screens/app/CONTEXT.md b/genlatte/genlatte-ui/lib/src/screens/app/CONTEXT.md new file mode 100644 index 0000000..425f1f8 --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/screens/app/CONTEXT.md @@ -0,0 +1,20 @@ +# App Screens + +**Purpose:** +Acts as the root container and top-level entry point wrapper for the entire application widget tree, injecting global themes and managing the root `MaterialApp.router`. + +**Detailed File Overviews:** + +- `app.dart`: + - **Description**: Barrel export file. + +- `app_view.dart`: + - **Description**: The core `App` widget wrapper. + - **Core Logic**: Consumes the `AppRouter` instance to construct a `ShadApp.router`, providing the top-level application branding and resolving the `theme`. + +- `theme.dart`: + - **Description**: Global styling constants. + - **Core Logic**: Provides the foundational custom typography (featuring `Unbounded` for display headers and `Open Sans` for body text) mapped against `shadcn_flutter`'s theme builder. Defines exact Hex color tokens matching branding guidelines. + +**Dependencies/Relationships:** +- Serves as the immediate child of the `runApp()` method in `lib/main.dart`. diff --git a/genlatte/genlatte-ui/lib/src/screens/app/app.dart b/genlatte/genlatte-ui/lib/src/screens/app/app.dart new file mode 100644 index 0000000..dc8254c --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/screens/app/app.dart @@ -0,0 +1,6 @@ +// Copyright 2014 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 'app_view.dart'; +export 'theme.dart'; diff --git a/genlatte/genlatte-ui/lib/src/screens/app/app_view.dart b/genlatte/genlatte-ui/lib/src/screens/app/app_view.dart new file mode 100644 index 0000000..09e02d3 --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/screens/app/app_view.dart @@ -0,0 +1,58 @@ +// Copyright 2014 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_bloc/flutter_bloc.dart'; +import 'package:genlatte/src/core/core.dart'; +import 'package:genlatte/src/screens/app/theme.dart'; +import 'package:provider/provider.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +/// {@template AppView} +/// Root widget for the application. +/// {@endtemplate} +class AppView extends StatelessWidget { + /// {@macro AppView} + const AppView({ + required this.authBloc, + required this.appRouter, + super.key, + }); + + /// {@macro AuthBloc} + final AuthBloc authBloc; + + /// {@macro AppRouter} + final AppRouter appRouter; + + @override + Widget build(BuildContext context) { + return MultiProvider( + providers: [ + Provider.value(value: appRouter), + ], + child: MultiBlocProvider( + providers: [ + BlocProvider.value(value: authBloc), + ], + child: _AppView(appRouter: appRouter), + ), + ); + } +} + +class _AppView extends StatelessWidget { + const _AppView({required this.appRouter}); + + final AppRouter appRouter; + + @override + Widget build(BuildContext context) { + return ShadcnApp.router( + debugShowCheckedModeBanner: false, + title: 'GenLatte', + theme: getThemeForOrientation(context), + routerConfig: appRouter.router, + ); + } +} diff --git a/genlatte/genlatte-ui/lib/src/screens/app/theme.dart b/genlatte/genlatte-ui/lib/src/screens/app/theme.dart new file mode 100644 index 0000000..bf91403 --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/screens/app/theme.dart @@ -0,0 +1,177 @@ +// Copyright 2014 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:google_fonts/google_fonts.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +/// Colors namespace. +class AppColors { + /// Hot pink. + static const placeholderColor = Color(0xFFDD40E4); + + /// Main gold of the "GenLatte" label. + static const latteArtGold = Color(0xFFECC03B); + + /// Extremely bold yellow. + static const chevronYellow = Color(0xFFFBBC04); + + /// Google blue. + static const googleBlue = Color(0xFF5F83EE); + + /// Google red. + static const googleIntroRed = Color(0xFFD85140); + + /// Google intro blue. + static const googleIntroBlue = Color(0xFF5383EC); + + /// Google intro green. + static const googleIntroGreen = Color(0xFF58A65C); + + /// Inactive grey. + static const mutedGrey = Color(0xFF484848); + + /// Placeholder grey (black with 30% opacity). + static const placeholderGrey = Color(0x4D000000); + + /// Black. + static const black = Color(0xFF000000); + + /// Pretty close to black. + static const almostBlack = Color(0xFF121212); + + /// White. + static const white = Color(0xFFFFFFFF); + + /// Transparent. + static const transparent = Color(0x00000000); +} + +const _colorScheme = ColorScheme( + brightness: Brightness.dark, + + // Lighter color which sits behind the slightly darker circles + background: AppColors.almostBlack, + + // Base text color. + foreground: Colors.black, + card: AppColors.white, + cardForeground: AppColors.black, + popover: AppColors.white, + popoverForeground: AppColors.black, + + // Google-y Blue + primary: AppColors.googleBlue, + primaryForeground: AppColors.black, + secondary: Color(0xFFCCCCCC), + secondaryForeground: AppColors.googleBlue, + muted: AppColors.mutedGrey, + + // Text input "placeholder" color + mutedForeground: AppColors.placeholderGrey, + accent: AppColors.googleBlue, + accentForeground: AppColors.placeholderColor, + destructive: AppColors.googleIntroRed, + border: AppColors.white, + input: AppColors.white, + + // Highlight around focused elements, like text inputs + ring: AppColors.googleBlue, + chart1: AppColors.placeholderColor, + chart2: AppColors.placeholderColor, + chart3: AppColors.placeholderColor, + chart4: AppColors.placeholderColor, + chart5: AppColors.placeholderColor, +); + +/// Canonicalize an alternate color scheme which creates dark borders; useful +/// for forms with text inputs which would really benefit from having visible +/// borders. +/// +/// The issue is that buttons also use `border` for their colors; and some +/// buttons have different border colors than text inputs. +final ColorScheme _formsColorScheme = _colorScheme.copyWith( + border: () => AppColors.almostBlack, +); + +/// How them letters should look. +final Typography typography = Typography( + sans: GoogleFonts.googleSans(), + mono: GoogleFonts.jetBrainsMono(), + xSmall: const TextStyle(fontSize: 10), + small: const TextStyle(fontSize: 13.5), + base: const TextStyle(fontSize: 16), + large: const TextStyle(fontSize: 18), + xLarge: const TextStyle(fontSize: 20), + x2Large: const TextStyle(fontSize: 24), + x3Large: const TextStyle(fontSize: 30), + x4Large: const TextStyle(fontSize: 36), + x5Large: const TextStyle(fontSize: 48), + x6Large: const TextStyle(fontSize: 60), + x7Large: const TextStyle(fontSize: 72), + x8Large: const TextStyle(fontSize: 96), + x9Large: const TextStyle(fontSize: 144), + thin: const TextStyle(fontWeight: FontWeight.w100), + light: const TextStyle(fontWeight: FontWeight.w300), + extraLight: const TextStyle(fontWeight: FontWeight.w200), + normal: const TextStyle(fontWeight: FontWeight.w400), + medium: const TextStyle(fontWeight: FontWeight.w500), + semiBold: const TextStyle(fontWeight: FontWeight.w600), + bold: const TextStyle(fontWeight: FontWeight.w700), + extraBold: const TextStyle(fontWeight: FontWeight.w800), + black: const TextStyle(fontWeight: FontWeight.w900), + italic: const TextStyle(fontStyle: FontStyle.italic), + + h1: const TextStyle( + fontSize: 48, + fontWeight: FontWeight.w800, + color: AppColors.black, + ), + h2: const TextStyle( + fontSize: 36, + fontWeight: FontWeight.w600, + color: AppColors.black, + ), + h3: const TextStyle( + fontSize: 24, + fontWeight: FontWeight.w600, + color: AppColors.black, + ), + // Used for app bars of pages, which are intentionally smaller than other + // headers used for prominent cards + h4: const TextStyle( + fontSize: 28, + fontWeight: FontWeight.w400, + color: AppColors.latteArtGold, + ), + p: const TextStyle(fontSize: 16, fontWeight: FontWeight.w400), + blockQuote: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w400, + fontStyle: FontStyle.italic, + ), + inlineCode: GoogleFonts.jetBrainsMono().copyWith( + fontSize: 14, + fontWeight: FontWeight.w600, + ), + lead: const TextStyle(fontSize: 20), + textLarge: const TextStyle(fontSize: 20, fontWeight: FontWeight.w600), + textSmall: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500), + textMuted: const TextStyle(fontSize: 14, fontWeight: FontWeight.w400), +); + +/// Returns the appropriate theme for the current orientation. +ThemeData getThemeForOrientation(BuildContext context) { + return ThemeData( + colorScheme: _colorScheme, + typography: typography, + radius: 0.6, + ); +} + +/// Special theme for forms with text inputs which are otherwise white-on-white. +final ThemeData formsTheme = ThemeData( + colorScheme: _formsColorScheme, + typography: typography, + radius: 0.6, +); diff --git a/genlatte/genlatte-ui/lib/src/screens/barista/CONTEXT.md b/genlatte/genlatte-ui/lib/src/screens/barista/CONTEXT.md new file mode 100644 index 0000000..04df99c --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/screens/barista/CONTEXT.md @@ -0,0 +1,11 @@ +# Kiosk / Barista / Moderator / Queue / Recent Orders Roots + +**Purpose:** +This directory acts as the architectural boundary grouping for the application's distinct personas. + +**Implementation Details:** +- **Barrel Architecture**: This level strictly exports child module directories like `home/` and `widgets/` via an `index` / `barrel` file. +- **Child Contexts**: Please navigate into `home/` or `widgets/` subdirectories to view specific BLoC architectures, View layouts, and complex logic files tailored towards each app persona. + +**Dependencies:** +- Consumed by `core/routing/routes.dart` to map physical URLs to specific screen entry points. diff --git a/genlatte/genlatte-ui/lib/src/screens/barista/barista.dart b/genlatte/genlatte-ui/lib/src/screens/barista/barista.dart new file mode 100644 index 0000000..828b3c1 --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/screens/barista/barista.dart @@ -0,0 +1,5 @@ +// Copyright 2014 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 'home/barista_home.dart'; diff --git a/genlatte/genlatte-ui/lib/src/screens/barista/home/CONTEXT.md b/genlatte/genlatte-ui/lib/src/screens/barista/home/CONTEXT.md new file mode 100644 index 0000000..4609e51 --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/screens/barista/home/CONTEXT.md @@ -0,0 +1,28 @@ +# Barista Home + +**Purpose:** +Manages the application state, business logic, and UI view for the primary Barista interface. This area empowers baristas to sign in, select connected physical machines (printers), and pull/claim orders from the unified queue. + +**Detailed File Overviews:** + +- `barista_home.dart`: + - **Description**: A barrel export file. + - **Usage/Exports**: Exposes `barista_home_bloc.dart` and `barista_home_view.dart`. + +- `barista_home_bloc.dart`: + - **Description**: The core state management unit (BLoC) governing the barista's workflow. + - **Core Logic**: Handles multiple asynchronous streams: fetching the latest `LatteOrderMetadata` (brew queue), active `Barista` list, and available active `Machine`s. Contains extensive logic to sign in a barista, cache locally with Hive, bind them to a selected printer, and claim/complete specific orders (`ClaimOrder`, `CompleteOrder` events). + - **Exposed Methods**: Consumes events like `NewBrewQueueOrders`, `BaristaSignIn`, `ClaimOrder`, and `SelectedMachine`, subsequently yielding new `BaristaHomeState` frames. + +- `barista_home_bloc.freezed.dart`: + - **Description**: The automatically generated freezed boilerplate for `BaristaHomeEvent` and `BaristaHomeState` immutability and union types. + +- `barista_home_view.dart`: + - **Description**: Defines the `BaristaHomeScreen`, integrating `BaristaHomeBloc` state directly into the UI. + - **Core Logic**: Dynamically switches the layout depending on the `currentBarista` state—showing a `BaristaPersonaCard` login screen if null, or transitioning to the `InternalOrderQueues.barista` queue view if authenticated. Also spawns modal dialogs to compel machine selection if the barista hasn't connected to a printer. + - **Dependencies**: Depends heavily on `barista/widgets` (e.g. `BaristaPersonaCard`) and `widgets/InternalOrderQueues`. + +**Dependencies/Relationships:** +- Closely tied to the `genlatte_data` package repositories (`LatteOrdersRepository`, `Repository`). +- Depends on sibling module `barista/widgets` for specific UI modules. +- Coordinates with Firebase Auth for sign out capability. diff --git a/genlatte/genlatte-ui/lib/src/screens/barista/home/barista_home.dart b/genlatte/genlatte-ui/lib/src/screens/barista/home/barista_home.dart new file mode 100644 index 0000000..cc13f5e --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/screens/barista/home/barista_home.dart @@ -0,0 +1,6 @@ +// Copyright 2014 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 'barista_home_bloc.dart'; +export 'barista_home_view.dart'; diff --git a/genlatte/genlatte-ui/lib/src/screens/barista/home/barista_home_bloc.dart b/genlatte/genlatte-ui/lib/src/screens/barista/home/barista_home_bloc.dart new file mode 100644 index 0000000..55c2d2b --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/screens/barista/home/barista_home_bloc.dart @@ -0,0 +1,426 @@ +// Copyright 2014 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:data_layer/data_layer.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:genlatte/src/data/data.dart'; +import 'package:genlatte_data/models.dart'; +import 'package:get_it/get_it.dart'; +import 'package:logging/logging.dart'; +import 'package:uuid/uuid.dart'; + +part 'barista_home_bloc.freezed.dart'; + +final _logger = Logger('BaristaHomeBloc'); + +typedef _Emit = Emitter; + +/// {@template BaristaHomeBloc} +/// Bloc for the Barista Home screen. +/// {@endtemplate} +class BaristaHomeBloc extends Bloc { + /// {@macro BaristaHomeBloc} + BaristaHomeBloc() : super(BaristaHomeState.initial()) { + on( + (event, _Emit emit) => switch (event) { + NewBrewQueueOrders() => _onNewBrewQueueOrders(event, emit), + NewBaristas() => _onNewBaristas(event, emit), + BaristaSignIn() => _onBaristaSignIn(event, emit), + BaristaSignOut() => _onBaristaSignOut(event, emit), + ClaimOrder() => _onClaimOrder(event, emit), + CompleteOrder() => _onCompleteOrder(event, emit), + ReprintOrder() => _onReprintOrder(event, emit), + NewMachineList() => _onNewMachineList(event, emit), + SelectedMachineDisappeared() => _onSelectedMachineDisappeared( + event, + emit, + ), + SelectedMachine() => _onSelectedMachine(event, emit), + }, + ); + + _metadataRepository = GetIt.I>(); + _ordersRepository = GetIt.I(); + + _brewStreamSub = _metadataRepository + .watchList( + details: RequestDetails.read( + filter: const LatteOrderMetadataBrewQueue(), + ), + ) + .listen((metadata) => add(NewBrewQueueOrders(metadata))); + + _baristasRepository = GetIt.I>(); + + _baristaStreamSub = _baristasRepository.watchList().listen( + (baristas) => add(NewBaristas(baristas)), + ); + + _baristasRepository + .getItems( + details: RequestDetails( + filter: ActiveBaristaFilter(), + requestType: .local, + ), + ) + .then( + (baristas) { + if (baristas.length == 1) { + add(BaristaSignIn(baristas.first)); + } + }, + ) + .ignore(); + + _machinesRepository = GetIt.I>(); + + _machinesStreamSub = _machinesRepository + .watchList( + details: RequestDetails.read( + filter: const ActiveMachinesFilter(), + ), + ) + .listen( + (machines) => add(NewMachineList(machines)), + ); + } + + late final Repository _machinesRepository; + late final StreamSubscription> _machinesStreamSub; + + late final Repository _baristasRepository; + late final LatteOrdersRepository _ordersRepository; + late final Repository _metadataRepository; + + late final StreamSubscription> _brewStreamSub; + late final StreamSubscription> _baristaStreamSub; + + Future _onNewBrewQueueOrders( + NewBrewQueueOrders event, + _Emit emit, + ) async { + _logger.fine( + 'New brew queue: ${event.metadatas.map((m) => m.id!)}', + ); + + List lattes; + try { + lattes = await _ordersRepository.toLattes(event.metadatas); + } on DataException catch (e) { + _logger.severe(e.message); + return; + } + + emit( + state.copyWith( + brewQueue: lattes + // Older orders appear first + ..sort( + (a, b) => a.metadata.orderSubmittedTime!.compareTo( + b.metadata.orderSubmittedTime!, + ), + ), + ), + ); + } + + void _onNewBaristas(NewBaristas event, _Emit emit) { + _logger.fine( + 'New baristas queue: ${event.baristas} :: ' + 'currentBarista: [${state.currentBarista}]', + ); + emit(state.copyWith(baristas: event.baristas.toIdsMap())); + } + + Future _onBaristaSignIn(BaristaSignIn event, _Emit emit) async { + final allBaristas = await _baristasRepository.getItems( + details: RequestDetails.read(requestType: .allLocal), + ); + + Barista? signedInBarista; + for (final barista in allBaristas) { + if (barista.username == event.barista.username && + barista.persona == event.barista.persona) { + _logger.fine('Logging in user as existing barista: $barista'); + signedInBarista = barista; + break; + } + } + if (signedInBarista == null) { + final savedBarista = await _baristasRepository.setItem(event.barista); + if (savedBarista == null) { + _logger.severe( + 'Failed to save barista ${event.barista}. Received null from ' + '[setItem].', + ); + return; + } + signedInBarista = savedBarista; + } + emit(state.copyWith(currentBarista: signedInBarista)); + + /// Cache this locally, which will write it to Hive but not bother + /// sending the payload to Firestore. + await _baristasRepository.setItems( + [signedInBarista], + RequestDetails( + filter: ActiveBaristaFilter(), + requestType: .local, + ), + ); + } + + Future _onBaristaSignOut(BaristaSignOut event, _Emit emit) async { + // Remove the active Barista from the local cache. + await _baristasRepository.delete( + state.currentBarista!.id!, + RequestDetails( + filter: ActiveBaristaFilter(), + requestType: .local, + ), + ); + emit(state.copyWith(currentBarista: null)); + } + + Future _onClaimOrder(ClaimOrder event, _Emit emit) async { + if (state.selectedMachine == null) { + return emit( + state.copyWith( + error: MachinesError.machineDisconnected(), + selectedMachine: null, + ), + ); + } + + final latte = state.brewQueue.firstWhereOrNull( + (l) => l.order.id == event.orderId, + ); + + if (latte == null) { + _logger.warning( + 'Could not find latte for order id ${event.orderId}. ' + 'Maybe another barista claimed it at the same time?', + ); + return; + } + + final metadata = latte.metadata.copyWith( + baristaId: state.currentBarista?.id, + status: .inProgress, + ); + + final imageUrlToPrint = latte.metadata.isImageApproved == false + ? 'https://firebasestorage.googleapis.com/v0/b/gcdemos-26-int-dd-latteart.firebasestorage.app/o/latteImages%2FfallbackImage%2Ficon_flutter.png?alt=media&token=06a3ac7e-7929-4507-8105-9eddb4445892' + : latte.metadata.imageUrl!; + + // No need to capture the returned metadata, since it will get picked up + // by our open `watch` streams. + await _metadataRepository.setItem(metadata); + + await _ordersRepository.sendToPrinters( + imagePath: imageUrlToPrint, + machineName: state.selectedMachine!.name, + ); + } + + Future _onCompleteOrder(CompleteOrder event, _Emit emit) async { + final latte = state.brewQueue.firstWhereOrNull( + (l) => l.order.id == event.orderId, + ); + + if (latte == null) { + _logger.warning( + 'Could not find latte for order id ${event.orderId}. ' + 'Maybe another barista completed it at the same time?', + ); + return; + } + + /// Optimistic update to immediately remove the order from the UI. + emit( + state.copyWith( + brewQueue: state.brewQueue + .where((l) => l.order.id != event.orderId) + .toList(), + ), + ); + + await _ordersRepository.completeOrder( + orderId: latte.order.id!, + baristaId: state.currentBarista!.id!, + ); + } + + Future _onReprintOrder(ReprintOrder event, _Emit emit) async { + if (state.selectedMachine == null) { + return emit( + state.copyWith( + error: MachinesError.machineDisconnected(), + selectedMachine: null, + ), + ); + } + + final latte = state.brewQueue.firstWhereOrNull( + (l) => l.order.id == event.orderId, + ); + + if (latte == null) { + _logger.warning( + 'Could not find latte for order id ${event.orderId}. ' + 'Maybe another barista completed it at the same time?', + ); + return; + } + + final imageUrlToPrint = latte.metadata.isImageApproved == false + ? 'https://firebasestorage.googleapis.com/v0/b/gcdemos-26-int-dd-latteart.firebasestorage.app/o/latteImages%2FfallbackImage%2Ficon_flutter.png?alt=media&token=06a3ac7e-7929-4507-8105-9eddb4445892' + : latte.metadata.imageUrl!; + + await _ordersRepository.sendToPrinters( + imagePath: imageUrlToPrint, + machineName: state.selectedMachine!.name, + ); + } + + void _onNewMachineList(NewMachineList event, _Emit emit) { + final machinesIdMap = event.machines.toIdsMap(); + final selectedMachine = state.selectedMachine; + if (selectedMachine != null && + !machinesIdMap.containsKey(selectedMachine.id)) { + add(const SelectedMachineDisappeared()); + } + emit(state.copyWith(machines: event.machines)); + } + + void _onSelectedMachineDisappeared( + SelectedMachineDisappeared event, + _Emit emit, + ) { + emit( + state.copyWith( + error: MachinesError.machineDisconnected(), + selectedMachine: null, + ), + ); + } + + void _onSelectedMachine(SelectedMachine event, _Emit emit) { + emit(state.copyWith(selectedMachine: event.machine, error: null)); + } + + @override + Future close() { + _brewStreamSub.cancel().ignore(); + _baristaStreamSub.cancel().ignore(); + _machinesStreamSub.cancel().ignore(); + return super.close(); + } +} + +/// Actions that can be taken on the BaristaHome page. +@Freezed() +sealed class BaristaHomeEvent with _$BaristaHomeEvent { + /// A new list of ready-to-brew orders has arrived from the server. + const factory BaristaHomeEvent.newBrewQueueOrders( + List metadatas, + ) = NewBrewQueueOrders; + + /// A new list of ready-to-brew orders has arrived from the server. + const factory BaristaHomeEvent.newMachinesList( + List machines, + ) = NewMachineList; + + /// The previously selected machine is no longer available. + const factory BaristaHomeEvent.selectedMachineDisappeared() = + SelectedMachineDisappeared; + + /// The barista has selected a new machine. + const factory BaristaHomeEvent.selectedMachine(Machine machine) = + SelectedMachine; + + /// A new list of Baristas has arrived from the server. + const factory BaristaHomeEvent.newBaristas(List baristas) = + NewBaristas; + + /// A new barista has signed in and selected their persona. + const factory BaristaHomeEvent.baristaSignIn(Barista barista) = BaristaSignIn; + + /// A barista has ended their shift. + const factory BaristaHomeEvent.baristaSignOut() = BaristaSignOut; + + /// The barista has claimed an order. + const factory BaristaHomeEvent.claimOrder(String orderId) = ClaimOrder; + + /// The barista has completed an order. + const factory BaristaHomeEvent.completeOrder(String orderId) = CompleteOrder; + + /// The barista has requested to reprint an order. + const factory BaristaHomeEvent.reprintOrder(String orderId) = ReprintOrder; +} + +/// {@template BaristaHomeState} +/// Complete representation of the BaristaHome page's state. +/// {@endtemplate +@Freezed() +sealed class BaristaHomeState with _$BaristaHomeState { + /// {@macro BaristaHomeState} + const factory BaristaHomeState({ + required List brewQueue, + Barista? currentBarista, + @Default({}) Map baristas, + @Default([]) List machines, + Machine? selectedMachine, + MachinesError? error, + }) = _BaristaHomeState; + const BaristaHomeState._(); + + /// Starter state fed to the [BaristaHomeBloc]. + factory BaristaHomeState.initial({List? brewQueue}) => + BaristaHomeState(brewQueue: brewQueue ?? []); +} + +extension _MyList on List { + /// Returns the first matching element, or null if there are no matches.` + E? firstWhereOrNull(bool Function(E element) test, {E? Function()? orElse}) { + for (final E element in this) { + if (test(element)) return element; + } + if (orElse != null) return orElse(); + return null; + } +} + +/// Error to show a Barista regarding their printer. The Uuid is just for +/// deduplication so the UI doesn't spam the same toast over and over. +class MachinesError { + /// Instantiates a [MachinesError]. + factory MachinesError.machineDisconnected() => MachinesError._( + 'The selected machine is no longer available. ' + 'Please choose another one.', + .machineDisconnected, + const Uuid().v4(), + ); + + MachinesError._(this.message, this.code, this.uuid); + + /// The message to show the barista. + final String message; + + /// The code associated with the error. + final ErrorCode code; + + /// Id for deduplication purposes in the widget tree. + final String uuid; +} + +/// Codes to identify the type of error. The UI uses this to know concretely +/// what action a Barista needs to take to rectify the error. +enum ErrorCode { + /// The Barista's previously-connected machine is no longer available. + machineDisconnected, +} diff --git a/genlatte/genlatte-ui/lib/src/screens/barista/home/barista_home_bloc.freezed.dart b/genlatte/genlatte-ui/lib/src/screens/barista/home/barista_home_bloc.freezed.dart new file mode 100644 index 0000000..a01bb7e --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/screens/barista/home/barista_home_bloc.freezed.dart @@ -0,0 +1,1186 @@ +// Copyright 2014 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. + +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'barista_home_bloc.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; +/// @nodoc +mixin _$BaristaHomeEvent { + + + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is BaristaHomeEvent); +} + + +@override +int get hashCode => runtimeType.hashCode; + +@override +String toString() { + return 'BaristaHomeEvent()'; +} + + +} + +/// @nodoc +class $BaristaHomeEventCopyWith<$Res> { +$BaristaHomeEventCopyWith(BaristaHomeEvent _, $Res Function(BaristaHomeEvent) __); +} + + +/// Adds pattern-matching-related methods to [BaristaHomeEvent]. +extension BaristaHomeEventPatterns on BaristaHomeEvent { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap({TResult Function( NewBrewQueueOrders value)? newBrewQueueOrders,TResult Function( NewMachineList value)? newMachinesList,TResult Function( SelectedMachineDisappeared value)? selectedMachineDisappeared,TResult Function( SelectedMachine value)? selectedMachine,TResult Function( NewBaristas value)? newBaristas,TResult Function( BaristaSignIn value)? baristaSignIn,TResult Function( BaristaSignOut value)? baristaSignOut,TResult Function( ClaimOrder value)? claimOrder,TResult Function( CompleteOrder value)? completeOrder,TResult Function( ReprintOrder value)? reprintOrder,required TResult orElse(),}){ +final _that = this; +switch (_that) { +case NewBrewQueueOrders() when newBrewQueueOrders != null: +return newBrewQueueOrders(_that);case NewMachineList() when newMachinesList != null: +return newMachinesList(_that);case SelectedMachineDisappeared() when selectedMachineDisappeared != null: +return selectedMachineDisappeared(_that);case SelectedMachine() when selectedMachine != null: +return selectedMachine(_that);case NewBaristas() when newBaristas != null: +return newBaristas(_that);case BaristaSignIn() when baristaSignIn != null: +return baristaSignIn(_that);case BaristaSignOut() when baristaSignOut != null: +return baristaSignOut(_that);case ClaimOrder() when claimOrder != null: +return claimOrder(_that);case CompleteOrder() when completeOrder != null: +return completeOrder(_that);case ReprintOrder() when reprintOrder != null: +return reprintOrder(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map({required TResult Function( NewBrewQueueOrders value) newBrewQueueOrders,required TResult Function( NewMachineList value) newMachinesList,required TResult Function( SelectedMachineDisappeared value) selectedMachineDisappeared,required TResult Function( SelectedMachine value) selectedMachine,required TResult Function( NewBaristas value) newBaristas,required TResult Function( BaristaSignIn value) baristaSignIn,required TResult Function( BaristaSignOut value) baristaSignOut,required TResult Function( ClaimOrder value) claimOrder,required TResult Function( CompleteOrder value) completeOrder,required TResult Function( ReprintOrder value) reprintOrder,}){ +final _that = this; +switch (_that) { +case NewBrewQueueOrders(): +return newBrewQueueOrders(_that);case NewMachineList(): +return newMachinesList(_that);case SelectedMachineDisappeared(): +return selectedMachineDisappeared(_that);case SelectedMachine(): +return selectedMachine(_that);case NewBaristas(): +return newBaristas(_that);case BaristaSignIn(): +return baristaSignIn(_that);case BaristaSignOut(): +return baristaSignOut(_that);case ClaimOrder(): +return claimOrder(_that);case CompleteOrder(): +return completeOrder(_that);case ReprintOrder(): +return reprintOrder(_that);} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull({TResult? Function( NewBrewQueueOrders value)? newBrewQueueOrders,TResult? Function( NewMachineList value)? newMachinesList,TResult? Function( SelectedMachineDisappeared value)? selectedMachineDisappeared,TResult? Function( SelectedMachine value)? selectedMachine,TResult? Function( NewBaristas value)? newBaristas,TResult? Function( BaristaSignIn value)? baristaSignIn,TResult? Function( BaristaSignOut value)? baristaSignOut,TResult? Function( ClaimOrder value)? claimOrder,TResult? Function( CompleteOrder value)? completeOrder,TResult? Function( ReprintOrder value)? reprintOrder,}){ +final _that = this; +switch (_that) { +case NewBrewQueueOrders() when newBrewQueueOrders != null: +return newBrewQueueOrders(_that);case NewMachineList() when newMachinesList != null: +return newMachinesList(_that);case SelectedMachineDisappeared() when selectedMachineDisappeared != null: +return selectedMachineDisappeared(_that);case SelectedMachine() when selectedMachine != null: +return selectedMachine(_that);case NewBaristas() when newBaristas != null: +return newBaristas(_that);case BaristaSignIn() when baristaSignIn != null: +return baristaSignIn(_that);case BaristaSignOut() when baristaSignOut != null: +return baristaSignOut(_that);case ClaimOrder() when claimOrder != null: +return claimOrder(_that);case CompleteOrder() when completeOrder != null: +return completeOrder(_that);case ReprintOrder() when reprintOrder != null: +return reprintOrder(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen({TResult Function( List metadatas)? newBrewQueueOrders,TResult Function( List machines)? newMachinesList,TResult Function()? selectedMachineDisappeared,TResult Function( Machine machine)? selectedMachine,TResult Function( List baristas)? newBaristas,TResult Function( Barista barista)? baristaSignIn,TResult Function()? baristaSignOut,TResult Function( String orderId)? claimOrder,TResult Function( String orderId)? completeOrder,TResult Function( String orderId)? reprintOrder,required TResult orElse(),}) {final _that = this; +switch (_that) { +case NewBrewQueueOrders() when newBrewQueueOrders != null: +return newBrewQueueOrders(_that.metadatas);case NewMachineList() when newMachinesList != null: +return newMachinesList(_that.machines);case SelectedMachineDisappeared() when selectedMachineDisappeared != null: +return selectedMachineDisappeared();case SelectedMachine() when selectedMachine != null: +return selectedMachine(_that.machine);case NewBaristas() when newBaristas != null: +return newBaristas(_that.baristas);case BaristaSignIn() when baristaSignIn != null: +return baristaSignIn(_that.barista);case BaristaSignOut() when baristaSignOut != null: +return baristaSignOut();case ClaimOrder() when claimOrder != null: +return claimOrder(_that.orderId);case CompleteOrder() when completeOrder != null: +return completeOrder(_that.orderId);case ReprintOrder() when reprintOrder != null: +return reprintOrder(_that.orderId);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when({required TResult Function( List metadatas) newBrewQueueOrders,required TResult Function( List machines) newMachinesList,required TResult Function() selectedMachineDisappeared,required TResult Function( Machine machine) selectedMachine,required TResult Function( List baristas) newBaristas,required TResult Function( Barista barista) baristaSignIn,required TResult Function() baristaSignOut,required TResult Function( String orderId) claimOrder,required TResult Function( String orderId) completeOrder,required TResult Function( String orderId) reprintOrder,}) {final _that = this; +switch (_that) { +case NewBrewQueueOrders(): +return newBrewQueueOrders(_that.metadatas);case NewMachineList(): +return newMachinesList(_that.machines);case SelectedMachineDisappeared(): +return selectedMachineDisappeared();case SelectedMachine(): +return selectedMachine(_that.machine);case NewBaristas(): +return newBaristas(_that.baristas);case BaristaSignIn(): +return baristaSignIn(_that.barista);case BaristaSignOut(): +return baristaSignOut();case ClaimOrder(): +return claimOrder(_that.orderId);case CompleteOrder(): +return completeOrder(_that.orderId);case ReprintOrder(): +return reprintOrder(_that.orderId);} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull({TResult? Function( List metadatas)? newBrewQueueOrders,TResult? Function( List machines)? newMachinesList,TResult? Function()? selectedMachineDisappeared,TResult? Function( Machine machine)? selectedMachine,TResult? Function( List baristas)? newBaristas,TResult? Function( Barista barista)? baristaSignIn,TResult? Function()? baristaSignOut,TResult? Function( String orderId)? claimOrder,TResult? Function( String orderId)? completeOrder,TResult? Function( String orderId)? reprintOrder,}) {final _that = this; +switch (_that) { +case NewBrewQueueOrders() when newBrewQueueOrders != null: +return newBrewQueueOrders(_that.metadatas);case NewMachineList() when newMachinesList != null: +return newMachinesList(_that.machines);case SelectedMachineDisappeared() when selectedMachineDisappeared != null: +return selectedMachineDisappeared();case SelectedMachine() when selectedMachine != null: +return selectedMachine(_that.machine);case NewBaristas() when newBaristas != null: +return newBaristas(_that.baristas);case BaristaSignIn() when baristaSignIn != null: +return baristaSignIn(_that.barista);case BaristaSignOut() when baristaSignOut != null: +return baristaSignOut();case ClaimOrder() when claimOrder != null: +return claimOrder(_that.orderId);case CompleteOrder() when completeOrder != null: +return completeOrder(_that.orderId);case ReprintOrder() when reprintOrder != null: +return reprintOrder(_that.orderId);case _: + return null; + +} +} + +} + +/// @nodoc + + +class NewBrewQueueOrders implements BaristaHomeEvent { + const NewBrewQueueOrders(final List metadatas): _metadatas = metadatas; + + + final List _metadatas; + List get metadatas { + if (_metadatas is EqualUnmodifiableListView) return _metadatas; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_metadatas); +} + + +/// Create a copy of BaristaHomeEvent +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$NewBrewQueueOrdersCopyWith get copyWith => _$NewBrewQueueOrdersCopyWithImpl(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is NewBrewQueueOrders&&const DeepCollectionEquality().equals(other._metadatas, _metadatas)); +} + + +@override +int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(_metadatas)); + +@override +String toString() { + return 'BaristaHomeEvent.newBrewQueueOrders(metadatas: $metadatas)'; +} + + +} + +/// @nodoc +abstract mixin class $NewBrewQueueOrdersCopyWith<$Res> implements $BaristaHomeEventCopyWith<$Res> { + factory $NewBrewQueueOrdersCopyWith(NewBrewQueueOrders value, $Res Function(NewBrewQueueOrders) _then) = _$NewBrewQueueOrdersCopyWithImpl; +@useResult +$Res call({ + List metadatas +}); + + + + +} +/// @nodoc +class _$NewBrewQueueOrdersCopyWithImpl<$Res> + implements $NewBrewQueueOrdersCopyWith<$Res> { + _$NewBrewQueueOrdersCopyWithImpl(this._self, this._then); + + final NewBrewQueueOrders _self; + final $Res Function(NewBrewQueueOrders) _then; + +/// Create a copy of BaristaHomeEvent +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') $Res call({Object? metadatas = null,}) { + return _then(NewBrewQueueOrders( +null == metadatas ? _self._metadatas : metadatas // ignore: cast_nullable_to_non_nullable +as List, + )); +} + + +} + +/// @nodoc + + +class NewMachineList implements BaristaHomeEvent { + const NewMachineList(final List machines): _machines = machines; + + + final List _machines; + List get machines { + if (_machines is EqualUnmodifiableListView) return _machines; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_machines); +} + + +/// Create a copy of BaristaHomeEvent +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$NewMachineListCopyWith get copyWith => _$NewMachineListCopyWithImpl(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is NewMachineList&&const DeepCollectionEquality().equals(other._machines, _machines)); +} + + +@override +int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(_machines)); + +@override +String toString() { + return 'BaristaHomeEvent.newMachinesList(machines: $machines)'; +} + + +} + +/// @nodoc +abstract mixin class $NewMachineListCopyWith<$Res> implements $BaristaHomeEventCopyWith<$Res> { + factory $NewMachineListCopyWith(NewMachineList value, $Res Function(NewMachineList) _then) = _$NewMachineListCopyWithImpl; +@useResult +$Res call({ + List machines +}); + + + + +} +/// @nodoc +class _$NewMachineListCopyWithImpl<$Res> + implements $NewMachineListCopyWith<$Res> { + _$NewMachineListCopyWithImpl(this._self, this._then); + + final NewMachineList _self; + final $Res Function(NewMachineList) _then; + +/// Create a copy of BaristaHomeEvent +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') $Res call({Object? machines = null,}) { + return _then(NewMachineList( +null == machines ? _self._machines : machines // ignore: cast_nullable_to_non_nullable +as List, + )); +} + + +} + +/// @nodoc + + +class SelectedMachineDisappeared implements BaristaHomeEvent { + const SelectedMachineDisappeared(); + + + + + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is SelectedMachineDisappeared); +} + + +@override +int get hashCode => runtimeType.hashCode; + +@override +String toString() { + return 'BaristaHomeEvent.selectedMachineDisappeared()'; +} + + +} + + + + +/// @nodoc + + +class SelectedMachine implements BaristaHomeEvent { + const SelectedMachine(this.machine); + + + final Machine machine; + +/// Create a copy of BaristaHomeEvent +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$SelectedMachineCopyWith get copyWith => _$SelectedMachineCopyWithImpl(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is SelectedMachine&&(identical(other.machine, machine) || other.machine == machine)); +} + + +@override +int get hashCode => Object.hash(runtimeType,machine); + +@override +String toString() { + return 'BaristaHomeEvent.selectedMachine(machine: $machine)'; +} + + +} + +/// @nodoc +abstract mixin class $SelectedMachineCopyWith<$Res> implements $BaristaHomeEventCopyWith<$Res> { + factory $SelectedMachineCopyWith(SelectedMachine value, $Res Function(SelectedMachine) _then) = _$SelectedMachineCopyWithImpl; +@useResult +$Res call({ + Machine machine +}); + + +$MachineCopyWith<$Res> get machine; + +} +/// @nodoc +class _$SelectedMachineCopyWithImpl<$Res> + implements $SelectedMachineCopyWith<$Res> { + _$SelectedMachineCopyWithImpl(this._self, this._then); + + final SelectedMachine _self; + final $Res Function(SelectedMachine) _then; + +/// Create a copy of BaristaHomeEvent +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') $Res call({Object? machine = null,}) { + return _then(SelectedMachine( +null == machine ? _self.machine : machine // ignore: cast_nullable_to_non_nullable +as Machine, + )); +} + +/// Create a copy of BaristaHomeEvent +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$MachineCopyWith<$Res> get machine { + + return $MachineCopyWith<$Res>(_self.machine, (value) { + return _then(_self.copyWith(machine: value)); + }); +} +} + +/// @nodoc + + +class NewBaristas implements BaristaHomeEvent { + const NewBaristas(final List baristas): _baristas = baristas; + + + final List _baristas; + List get baristas { + if (_baristas is EqualUnmodifiableListView) return _baristas; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_baristas); +} + + +/// Create a copy of BaristaHomeEvent +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$NewBaristasCopyWith get copyWith => _$NewBaristasCopyWithImpl(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is NewBaristas&&const DeepCollectionEquality().equals(other._baristas, _baristas)); +} + + +@override +int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(_baristas)); + +@override +String toString() { + return 'BaristaHomeEvent.newBaristas(baristas: $baristas)'; +} + + +} + +/// @nodoc +abstract mixin class $NewBaristasCopyWith<$Res> implements $BaristaHomeEventCopyWith<$Res> { + factory $NewBaristasCopyWith(NewBaristas value, $Res Function(NewBaristas) _then) = _$NewBaristasCopyWithImpl; +@useResult +$Res call({ + List baristas +}); + + + + +} +/// @nodoc +class _$NewBaristasCopyWithImpl<$Res> + implements $NewBaristasCopyWith<$Res> { + _$NewBaristasCopyWithImpl(this._self, this._then); + + final NewBaristas _self; + final $Res Function(NewBaristas) _then; + +/// Create a copy of BaristaHomeEvent +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') $Res call({Object? baristas = null,}) { + return _then(NewBaristas( +null == baristas ? _self._baristas : baristas // ignore: cast_nullable_to_non_nullable +as List, + )); +} + + +} + +/// @nodoc + + +class BaristaSignIn implements BaristaHomeEvent { + const BaristaSignIn(this.barista); + + + final Barista barista; + +/// Create a copy of BaristaHomeEvent +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$BaristaSignInCopyWith get copyWith => _$BaristaSignInCopyWithImpl(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is BaristaSignIn&&(identical(other.barista, barista) || other.barista == barista)); +} + + +@override +int get hashCode => Object.hash(runtimeType,barista); + +@override +String toString() { + return 'BaristaHomeEvent.baristaSignIn(barista: $barista)'; +} + + +} + +/// @nodoc +abstract mixin class $BaristaSignInCopyWith<$Res> implements $BaristaHomeEventCopyWith<$Res> { + factory $BaristaSignInCopyWith(BaristaSignIn value, $Res Function(BaristaSignIn) _then) = _$BaristaSignInCopyWithImpl; +@useResult +$Res call({ + Barista barista +}); + + +$BaristaCopyWith<$Res> get barista; + +} +/// @nodoc +class _$BaristaSignInCopyWithImpl<$Res> + implements $BaristaSignInCopyWith<$Res> { + _$BaristaSignInCopyWithImpl(this._self, this._then); + + final BaristaSignIn _self; + final $Res Function(BaristaSignIn) _then; + +/// Create a copy of BaristaHomeEvent +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') $Res call({Object? barista = null,}) { + return _then(BaristaSignIn( +null == barista ? _self.barista : barista // ignore: cast_nullable_to_non_nullable +as Barista, + )); +} + +/// Create a copy of BaristaHomeEvent +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$BaristaCopyWith<$Res> get barista { + + return $BaristaCopyWith<$Res>(_self.barista, (value) { + return _then(_self.copyWith(barista: value)); + }); +} +} + +/// @nodoc + + +class BaristaSignOut implements BaristaHomeEvent { + const BaristaSignOut(); + + + + + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is BaristaSignOut); +} + + +@override +int get hashCode => runtimeType.hashCode; + +@override +String toString() { + return 'BaristaHomeEvent.baristaSignOut()'; +} + + +} + + + + +/// @nodoc + + +class ClaimOrder implements BaristaHomeEvent { + const ClaimOrder(this.orderId); + + + final String orderId; + +/// Create a copy of BaristaHomeEvent +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$ClaimOrderCopyWith get copyWith => _$ClaimOrderCopyWithImpl(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is ClaimOrder&&(identical(other.orderId, orderId) || other.orderId == orderId)); +} + + +@override +int get hashCode => Object.hash(runtimeType,orderId); + +@override +String toString() { + return 'BaristaHomeEvent.claimOrder(orderId: $orderId)'; +} + + +} + +/// @nodoc +abstract mixin class $ClaimOrderCopyWith<$Res> implements $BaristaHomeEventCopyWith<$Res> { + factory $ClaimOrderCopyWith(ClaimOrder value, $Res Function(ClaimOrder) _then) = _$ClaimOrderCopyWithImpl; +@useResult +$Res call({ + String orderId +}); + + + + +} +/// @nodoc +class _$ClaimOrderCopyWithImpl<$Res> + implements $ClaimOrderCopyWith<$Res> { + _$ClaimOrderCopyWithImpl(this._self, this._then); + + final ClaimOrder _self; + final $Res Function(ClaimOrder) _then; + +/// Create a copy of BaristaHomeEvent +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') $Res call({Object? orderId = null,}) { + return _then(ClaimOrder( +null == orderId ? _self.orderId : orderId // ignore: cast_nullable_to_non_nullable +as String, + )); +} + + +} + +/// @nodoc + + +class CompleteOrder implements BaristaHomeEvent { + const CompleteOrder(this.orderId); + + + final String orderId; + +/// Create a copy of BaristaHomeEvent +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$CompleteOrderCopyWith get copyWith => _$CompleteOrderCopyWithImpl(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is CompleteOrder&&(identical(other.orderId, orderId) || other.orderId == orderId)); +} + + +@override +int get hashCode => Object.hash(runtimeType,orderId); + +@override +String toString() { + return 'BaristaHomeEvent.completeOrder(orderId: $orderId)'; +} + + +} + +/// @nodoc +abstract mixin class $CompleteOrderCopyWith<$Res> implements $BaristaHomeEventCopyWith<$Res> { + factory $CompleteOrderCopyWith(CompleteOrder value, $Res Function(CompleteOrder) _then) = _$CompleteOrderCopyWithImpl; +@useResult +$Res call({ + String orderId +}); + + + + +} +/// @nodoc +class _$CompleteOrderCopyWithImpl<$Res> + implements $CompleteOrderCopyWith<$Res> { + _$CompleteOrderCopyWithImpl(this._self, this._then); + + final CompleteOrder _self; + final $Res Function(CompleteOrder) _then; + +/// Create a copy of BaristaHomeEvent +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') $Res call({Object? orderId = null,}) { + return _then(CompleteOrder( +null == orderId ? _self.orderId : orderId // ignore: cast_nullable_to_non_nullable +as String, + )); +} + + +} + +/// @nodoc + + +class ReprintOrder implements BaristaHomeEvent { + const ReprintOrder(this.orderId); + + + final String orderId; + +/// Create a copy of BaristaHomeEvent +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$ReprintOrderCopyWith get copyWith => _$ReprintOrderCopyWithImpl(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is ReprintOrder&&(identical(other.orderId, orderId) || other.orderId == orderId)); +} + + +@override +int get hashCode => Object.hash(runtimeType,orderId); + +@override +String toString() { + return 'BaristaHomeEvent.reprintOrder(orderId: $orderId)'; +} + + +} + +/// @nodoc +abstract mixin class $ReprintOrderCopyWith<$Res> implements $BaristaHomeEventCopyWith<$Res> { + factory $ReprintOrderCopyWith(ReprintOrder value, $Res Function(ReprintOrder) _then) = _$ReprintOrderCopyWithImpl; +@useResult +$Res call({ + String orderId +}); + + + + +} +/// @nodoc +class _$ReprintOrderCopyWithImpl<$Res> + implements $ReprintOrderCopyWith<$Res> { + _$ReprintOrderCopyWithImpl(this._self, this._then); + + final ReprintOrder _self; + final $Res Function(ReprintOrder) _then; + +/// Create a copy of BaristaHomeEvent +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') $Res call({Object? orderId = null,}) { + return _then(ReprintOrder( +null == orderId ? _self.orderId : orderId // ignore: cast_nullable_to_non_nullable +as String, + )); +} + + +} + +/// @nodoc +mixin _$BaristaHomeState { + + List get brewQueue; Barista? get currentBarista; Map get baristas; List get machines; Machine? get selectedMachine; MachinesError? get error; +/// Create a copy of BaristaHomeState +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$BaristaHomeStateCopyWith get copyWith => _$BaristaHomeStateCopyWithImpl(this as BaristaHomeState, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is BaristaHomeState&&const DeepCollectionEquality().equals(other.brewQueue, brewQueue)&&(identical(other.currentBarista, currentBarista) || other.currentBarista == currentBarista)&&const DeepCollectionEquality().equals(other.baristas, baristas)&&const DeepCollectionEquality().equals(other.machines, machines)&&(identical(other.selectedMachine, selectedMachine) || other.selectedMachine == selectedMachine)&&(identical(other.error, error) || other.error == error)); +} + + +@override +int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(brewQueue),currentBarista,const DeepCollectionEquality().hash(baristas),const DeepCollectionEquality().hash(machines),selectedMachine,error); + +@override +String toString() { + return 'BaristaHomeState(brewQueue: $brewQueue, currentBarista: $currentBarista, baristas: $baristas, machines: $machines, selectedMachine: $selectedMachine, error: $error)'; +} + + +} + +/// @nodoc +abstract mixin class $BaristaHomeStateCopyWith<$Res> { + factory $BaristaHomeStateCopyWith(BaristaHomeState value, $Res Function(BaristaHomeState) _then) = _$BaristaHomeStateCopyWithImpl; +@useResult +$Res call({ + List brewQueue, Barista? currentBarista, Map baristas, List machines, Machine? selectedMachine, MachinesError? error +}); + + +$BaristaCopyWith<$Res>? get currentBarista;$MachineCopyWith<$Res>? get selectedMachine; + +} +/// @nodoc +class _$BaristaHomeStateCopyWithImpl<$Res> + implements $BaristaHomeStateCopyWith<$Res> { + _$BaristaHomeStateCopyWithImpl(this._self, this._then); + + final BaristaHomeState _self; + final $Res Function(BaristaHomeState) _then; + +/// Create a copy of BaristaHomeState +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? brewQueue = null,Object? currentBarista = freezed,Object? baristas = null,Object? machines = null,Object? selectedMachine = freezed,Object? error = freezed,}) { + return _then(_self.copyWith( +brewQueue: null == brewQueue ? _self.brewQueue : brewQueue // ignore: cast_nullable_to_non_nullable +as List,currentBarista: freezed == currentBarista ? _self.currentBarista : currentBarista // ignore: cast_nullable_to_non_nullable +as Barista?,baristas: null == baristas ? _self.baristas : baristas // ignore: cast_nullable_to_non_nullable +as Map,machines: null == machines ? _self.machines : machines // ignore: cast_nullable_to_non_nullable +as List,selectedMachine: freezed == selectedMachine ? _self.selectedMachine : selectedMachine // ignore: cast_nullable_to_non_nullable +as Machine?,error: freezed == error ? _self.error : error // ignore: cast_nullable_to_non_nullable +as MachinesError?, + )); +} +/// Create a copy of BaristaHomeState +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$BaristaCopyWith<$Res>? get currentBarista { + if (_self.currentBarista == null) { + return null; + } + + return $BaristaCopyWith<$Res>(_self.currentBarista!, (value) { + return _then(_self.copyWith(currentBarista: value)); + }); +}/// Create a copy of BaristaHomeState +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$MachineCopyWith<$Res>? get selectedMachine { + if (_self.selectedMachine == null) { + return null; + } + + return $MachineCopyWith<$Res>(_self.selectedMachine!, (value) { + return _then(_self.copyWith(selectedMachine: value)); + }); +} +} + + +/// Adds pattern-matching-related methods to [BaristaHomeState]. +extension BaristaHomeStatePatterns on BaristaHomeState { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _BaristaHomeState value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _BaristaHomeState() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _BaristaHomeState value) $default,){ +final _that = this; +switch (_that) { +case _BaristaHomeState(): +return $default(_that);} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _BaristaHomeState value)? $default,){ +final _that = this; +switch (_that) { +case _BaristaHomeState() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( List brewQueue, Barista? currentBarista, Map baristas, List machines, Machine? selectedMachine, MachinesError? error)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _BaristaHomeState() when $default != null: +return $default(_that.brewQueue,_that.currentBarista,_that.baristas,_that.machines,_that.selectedMachine,_that.error);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( List brewQueue, Barista? currentBarista, Map baristas, List machines, Machine? selectedMachine, MachinesError? error) $default,) {final _that = this; +switch (_that) { +case _BaristaHomeState(): +return $default(_that.brewQueue,_that.currentBarista,_that.baristas,_that.machines,_that.selectedMachine,_that.error);} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( List brewQueue, Barista? currentBarista, Map baristas, List machines, Machine? selectedMachine, MachinesError? error)? $default,) {final _that = this; +switch (_that) { +case _BaristaHomeState() when $default != null: +return $default(_that.brewQueue,_that.currentBarista,_that.baristas,_that.machines,_that.selectedMachine,_that.error);case _: + return null; + +} +} + +} + +/// @nodoc + + +class _BaristaHomeState extends BaristaHomeState { + const _BaristaHomeState({required final List brewQueue, this.currentBarista, final Map baristas = const {}, final List machines = const [], this.selectedMachine, this.error}): _brewQueue = brewQueue,_baristas = baristas,_machines = machines,super._(); + + + final List _brewQueue; +@override List get brewQueue { + if (_brewQueue is EqualUnmodifiableListView) return _brewQueue; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_brewQueue); +} + +@override final Barista? currentBarista; + final Map _baristas; +@override@JsonKey() Map get baristas { + if (_baristas is EqualUnmodifiableMapView) return _baristas; + // ignore: implicit_dynamic_type + return EqualUnmodifiableMapView(_baristas); +} + + final List _machines; +@override@JsonKey() List get machines { + if (_machines is EqualUnmodifiableListView) return _machines; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_machines); +} + +@override final Machine? selectedMachine; +@override final MachinesError? error; + +/// Create a copy of BaristaHomeState +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$BaristaHomeStateCopyWith<_BaristaHomeState> get copyWith => __$BaristaHomeStateCopyWithImpl<_BaristaHomeState>(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _BaristaHomeState&&const DeepCollectionEquality().equals(other._brewQueue, _brewQueue)&&(identical(other.currentBarista, currentBarista) || other.currentBarista == currentBarista)&&const DeepCollectionEquality().equals(other._baristas, _baristas)&&const DeepCollectionEquality().equals(other._machines, _machines)&&(identical(other.selectedMachine, selectedMachine) || other.selectedMachine == selectedMachine)&&(identical(other.error, error) || other.error == error)); +} + + +@override +int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(_brewQueue),currentBarista,const DeepCollectionEquality().hash(_baristas),const DeepCollectionEquality().hash(_machines),selectedMachine,error); + +@override +String toString() { + return 'BaristaHomeState(brewQueue: $brewQueue, currentBarista: $currentBarista, baristas: $baristas, machines: $machines, selectedMachine: $selectedMachine, error: $error)'; +} + + +} + +/// @nodoc +abstract mixin class _$BaristaHomeStateCopyWith<$Res> implements $BaristaHomeStateCopyWith<$Res> { + factory _$BaristaHomeStateCopyWith(_BaristaHomeState value, $Res Function(_BaristaHomeState) _then) = __$BaristaHomeStateCopyWithImpl; +@override @useResult +$Res call({ + List brewQueue, Barista? currentBarista, Map baristas, List machines, Machine? selectedMachine, MachinesError? error +}); + + +@override $BaristaCopyWith<$Res>? get currentBarista;@override $MachineCopyWith<$Res>? get selectedMachine; + +} +/// @nodoc +class __$BaristaHomeStateCopyWithImpl<$Res> + implements _$BaristaHomeStateCopyWith<$Res> { + __$BaristaHomeStateCopyWithImpl(this._self, this._then); + + final _BaristaHomeState _self; + final $Res Function(_BaristaHomeState) _then; + +/// Create a copy of BaristaHomeState +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? brewQueue = null,Object? currentBarista = freezed,Object? baristas = null,Object? machines = null,Object? selectedMachine = freezed,Object? error = freezed,}) { + return _then(_BaristaHomeState( +brewQueue: null == brewQueue ? _self._brewQueue : brewQueue // ignore: cast_nullable_to_non_nullable +as List,currentBarista: freezed == currentBarista ? _self.currentBarista : currentBarista // ignore: cast_nullable_to_non_nullable +as Barista?,baristas: null == baristas ? _self._baristas : baristas // ignore: cast_nullable_to_non_nullable +as Map,machines: null == machines ? _self._machines : machines // ignore: cast_nullable_to_non_nullable +as List,selectedMachine: freezed == selectedMachine ? _self.selectedMachine : selectedMachine // ignore: cast_nullable_to_non_nullable +as Machine?,error: freezed == error ? _self.error : error // ignore: cast_nullable_to_non_nullable +as MachinesError?, + )); +} + +/// Create a copy of BaristaHomeState +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$BaristaCopyWith<$Res>? get currentBarista { + if (_self.currentBarista == null) { + return null; + } + + return $BaristaCopyWith<$Res>(_self.currentBarista!, (value) { + return _then(_self.copyWith(currentBarista: value)); + }); +}/// Create a copy of BaristaHomeState +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$MachineCopyWith<$Res>? get selectedMachine { + if (_self.selectedMachine == null) { + return null; + } + + return $MachineCopyWith<$Res>(_self.selectedMachine!, (value) { + return _then(_self.copyWith(selectedMachine: value)); + }); +} +} + +// dart format on diff --git a/genlatte/genlatte-ui/lib/src/screens/barista/home/barista_home_view.dart b/genlatte/genlatte-ui/lib/src/screens/barista/home/barista_home_view.dart new file mode 100644 index 0000000..a4ca330 --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/screens/barista/home/barista_home_view.dart @@ -0,0 +1,251 @@ +// Copyright 2014 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:math'; + +import 'package:firebase_analytics/firebase_analytics.dart'; +import 'package:firebase_auth/firebase_auth.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:genlatte/src/screens/app/app.dart'; +import 'package:genlatte/src/screens/barista/home/barista_home.dart'; +import 'package:genlatte/src/screens/barista/widgets/widgets.dart'; +import 'package:genlatte/src/widgets/widgets.dart'; +import 'package:genlatte_data/models.dart'; +import 'package:get_it/get_it.dart' show GetIt; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +/// {@template BaristaHomeScreen} +/// Initial BaristaHome screen. +/// {@endtemplate} +class BaristaHomeScreen extends StatefulWidget { + /// {@macro BaristaHomeScreen} + const BaristaHomeScreen({super.key}); + + @override + State createState() => _BaristaHomeScreenState(); +} + +class _BaristaHomeScreenState extends State { + final BaristaHomeBloc bloc = BaristaHomeBloc(); + + final shownErrorUuids = {}; + + bool _hasPerformedInitialMachineCheck = false; + + @override + Widget build(BuildContext context) { + return BlocBuilder( + bloc: bloc, + builder: (context, state) { + final size = MediaQuery.sizeOf(context); + return BlocListener( + bloc: bloc, + listener: (context, state) { + if (state.error != null && + !shownErrorUuids.contains(state.error!.uuid)) { + shownErrorUuids.add(state.error!.uuid); + _showErrorToast(state.error!, state.machines).ignore(); + } + + if (!_hasPerformedInitialMachineCheck && + state.machines.isNotEmpty && + state.selectedMachine == null) { + _hasPerformedInitialMachineCheck = true; + _showMachineSelectionToast(state.machines).ignore(); + } + }, + child: Scaffold( + headers: [ + if (state.currentBarista != null) + AppBar( + backgroundColor: AppColors.almostBlack, + leading: [ + Avatar( + initials: state.currentBarista!.username.substring(0, 1), + provider: AssetImage( + state.currentBarista!.persona.assetName, + ), + ), + const SizedBox(width: 8), + if (state.selectedMachine != null) + GenLatteOutlinedButton.light( + label: + '${state.selectedMachine!.name} ' + // ignore: lines_longer_than_80_chars + '${state.selectedMachine!.isBlackAndWhite ? '🔲' : '🎨'}', + onPressed: () => _showMachineSelectionToast( + state.machines, + selectedMachine: state.selectedMachine, + ), + ), + if (state.selectedMachine == null) + GenLatteOutlinedButton.red( + label: 'Select a Machine', + onPressed: () => _showMachineSelectionToast( + state.machines, + ), + ), + ], + title: Center( + child: const Text( + 'Barista Queue', + style: TextStyle(color: AppColors.white), + ).h3, + ), + trailing: [ + GenLatteOutlinedButton.light( + label: 'End your watch', + onPressed: () => bloc.add(const BaristaSignOut()), + ), + ], + ), + ], + child: state.currentBarista == null + ? Padding( + padding: EdgeInsets.symmetric( + horizontal: size.width * 0.15, + vertical: size.height * 0.1, + ), + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Expanded( + flex: 10, + child: BaristaPersonaCard( + onSubmit: (username, persona) => bloc.add( + BaristaSignIn( + Barista(username: username, persona: persona), + ), + ), + ), + ), + Expanded( + child: GhostButton( + onPressed: () => + GetIt.I().signOut(), + child: const Center( + child: Padding( + padding: EdgeInsets.all(8), + child: Text( + 'Logout', + style: TextStyle( + fontSize: 20, + color: AppColors.white, + fontWeight: FontWeight.w600, + letterSpacing: 0.7, + ), + ), + ), + ), + ), + ), + ], + ), + ), + ) + : InternalOrderQueues.barista( + activeBarista: state.currentBarista!, + canClaimOrders: state.selectedMachine != null, + baristas: state.baristas, + orders: state.brewQueue, + onClaimPressed: (orderId) async { + bloc.add(ClaimOrder(orderId)); + await GetIt.I.get().logEvent( + name: 'barista_claim_order', + ); + }, + onCompletePressed: (orderId) async { + bloc.add(CompleteOrder(orderId)); + await GetIt.I.get().logEvent( + name: 'barista_complete_order', + ); + }, + onReprintPressed: (orderId) async { + bloc.add(ReprintOrder(orderId)); + await GetIt.I.get().logEvent( + name: 'barista_reprint_order', + ); + }, + ), + ), + ); + }, + ); + } + + Future _showMachineSelectionToast( + List machines, { + Machine? selectedMachine, + }) { + return showDialog( + context: context, + builder: (context) { + final size = MediaQuery.sizeOf(context); + return SizedBox( + width: min(640, size.width * 0.8), + child: AlertDialog( + title: const Text('Select a Machine').bold, + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 16), + MachineSelection( + machines: machines, + initialSelection: selectedMachine, + onSubmitted: (machine) { + bloc.add(SelectedMachine(machine)); + Navigator.pop(context); + }, + ), + ], + ), + ), + ); + }, + ); + } + + Future _showErrorToast( + MachinesError error, + List machines, + ) async { + switch (error.code) { + case ErrorCode.machineDisconnected: + await showDialog( + context: context, + builder: (context) { + final size = MediaQuery.sizeOf(context); + return SizedBox( + width: min(640, size.width * 0.8), + child: AlertDialog( + title: const Text('Machine Disconnected').bold, + content: Column( + crossAxisAlignment: .start, + mainAxisSize: .min, + children: [ + Text(error.message), + const SizedBox(height: 16), + MachineSelection( + machines: machines, + onSubmitted: (machine) { + bloc.add(SelectedMachine(machine)); + Navigator.pop(context); + }, + ), + ], + ), + ), + ); + }, + ); + } + } + + @override + Future dispose() async { + await bloc.close(); + super.dispose(); + } +} diff --git a/genlatte/genlatte-ui/lib/src/screens/barista/widgets/CONTEXT.md b/genlatte/genlatte-ui/lib/src/screens/barista/widgets/CONTEXT.md new file mode 100644 index 0000000..ef3fcb4 --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/screens/barista/widgets/CONTEXT.md @@ -0,0 +1,36 @@ +# Barista Widgets + +**Purpose:** +A repository of reusable widgets tailored specifically for the barista-facing application interfaces. These components elegantly display orders, present the barista persona, and visualize the status of the local queues. + +**Detailed File Overviews:** + +- `barista_persona_card.dart`: + - **Description**: UI component for setting up a barista persona (authentication). + - **Core Logic**: Manages a local text controller for username input and a selectable grid of AI-generated avatars. Applies grayscale filters to unselected avatars using `ColorFilter.matrix` and triggers an `onSubmit` callback upon completion. + - **Usage/Exports**: Used heavily by the `BaristaHomeScreen` to instantiate a Barista session. + +- `humanized_countdown.dart`: + - **Description**: Widget rendering a time differential (e.g. "5m ago"). + +- `image_stack.dart`: + - **Description**: Displays the generated latte image overlaid with the customer's name. + - **Core Logic**: Reads `LatteOrderMetadata` to determine if an image was approved or rejected by moderation. Injects fallback URLs if needed, and applies colored banners visually denoting rejection statuses. + +- `internal_order_queues.dart`: + - **Description**: Renders horizontal, segregated scroll lists displaying cards for all orders ready for moderation or brewing. + - **Core Logic**: Handles role-based visibility. For `barista` roles, it only prints the brew queue. For `moderator` roles, it also prints the moderation queue. Loops over a list of `Latte` objects, outputting either barista or moderator variants of `LatteOrderCard`. + +- `latte_order_card.dart`: + - **Description**: The core visual representation of a single latte order. + - **Core Logic**: Intensely stateful due to a built-in flipping animation (using `Matrix4` rotation/perspective), providing a dual-sided card. One side (`_LatteOrderCardInner.moderate`) exposes AI moderation controls (Approve/Reject), and the reverse side (`_LatteOrderCardInner.brew`) displays physical brew buttons (Claim/Complete), a badge for the assigned barista, and details regarding Milk/Sweetener selections. + +- `order_details_table.dart`: + - **Description**: A tabular layout for printing the `OrderDetailItem` list (Milk, Sweetener, Drink Type). + +- `widgets.dart`: + - **Description**: Barrel export file. + +**Dependencies/Relationships:** +- Strongly coupled to `genlatte_data`'s data models (`Latte`, `LatteOrderMetadata`, `LatteImageBatch`). +- Intended for consumption strictly by `genlatte-ui/lib/src/screens/barista` and `moderator` screens. diff --git a/genlatte/genlatte-ui/lib/src/screens/barista/widgets/barista_persona_card.dart b/genlatte/genlatte-ui/lib/src/screens/barista/widgets/barista_persona_card.dart new file mode 100644 index 0000000..85e5fae --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/screens/barista/widgets/barista_persona_card.dart @@ -0,0 +1,252 @@ +// Copyright 2014 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:genlatte/src/screens/app/app.dart'; +import 'package:genlatte/src/widgets/widgets.dart'; +import 'package:genlatte_data/models.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +/// {@template BaristaPersonaCard} +/// A card for setting up a barista persona. +/// {@endtemplate} +class BaristaPersonaCard extends StatefulWidget { + /// {@macro BaristaPersonaCard} + const BaristaPersonaCard({ + required this.onSubmit, + super.key, + }); + + /// Callback for when the user submits their persona. + final void Function(String username, BaristaPersona persona) onSubmit; + + @override + State createState() => _BaristaPersonaCardState(); +} + +class _BaristaPersonaCardState extends State { + final TextEditingController _usernameController = TextEditingController(); + int? _selectedAvatarIndex; + + @override + void initState() { + super.initState(); + _usernameController.addListener(_onUsernameChanged); + } + + void _onUsernameChanged() { + setState(() {}); + } + + @override + void dispose() { + _usernameController.dispose(); + super.dispose(); + } + + // Generated by Gemini. Who knows. + static const greyScaleMatrix = [ + ...[0.2126, 0.7152, 0.0722, 0], + ...[0, 0.2126, 0.7152, 0.0722], + ...[0, 0, 0.2126, 0.7152], + ...[0.0722, 0, 0, 0], + ...[0, 0, 1, 0], + ]; + + static const _idealSize = Size(520, 520); + + @override + Widget build(BuildContext context) { + return ResponsiveSizedBox( + aspectRatioClamp: (null, _idealSize.aspectRatio, null), + child: LayoutProvider.builder( + builder: (context, layoutInfo) { + final scale = layoutInfo.constrainedScale(_idealSize); + return Container( + width: layoutInfo.width, + padding: EdgeInsets.symmetric( + horizontal: 24 * scale, + vertical: 32 * scale, + ), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20 * scale), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.05), + blurRadius: 25 * scale, + offset: Offset(0, 10 * scale), + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Header + Text( + 'Set up your profile', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 24 * scale, + fontWeight: FontWeight.bold, + color: const Color(0xFF111827), + ), + ), + SizedBox(height: 8 * scale), + Text( + 'Choose a username and your avatar.', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 14 * scale, + color: const Color(0xFF6B7280), + ), + ), + const SizedBox(height: 28), + + // Username Input + Text( + 'Username', + style: TextStyle( + fontSize: 13 * scale, + fontWeight: FontWeight.w600, + color: const Color(0xFF374151), + ), + ), + SizedBox(height: 8 * scale), + TextField( + controller: _usernameController, + decoration: BoxDecoration( + border: Border.all( + color: AppColors.mutedGrey, + ), // defaults to BorderStyle.none + borderRadius: BorderRadius.all( + Radius.circular(8 * scale), + ), + color: Theme.of(context).colorScheme.input, + ), + // onChanged: widget.onSelected, + placeholder: Text( + 'e.g. Your name', + style: TextStyle(fontSize: 16 * scale), + ), + padding: EdgeInsets.symmetric( + horizontal: 16 * scale, + vertical: 12 * scale, + ), + style: TextStyle( + fontSize: 16 * scale, + ), + ), + SizedBox(height: 24 * scale), + + // Avatar Grid + Text( + 'Select Avatar', + style: TextStyle( + fontSize: 13 * scale, + fontWeight: FontWeight.w600, + color: const Color(0xFF374151), + ), + ), + SizedBox(height: 8 * scale), + GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 5, + crossAxisSpacing: 12, + mainAxisSpacing: 12, + ), + itemCount: BaristaPersona.values.length, + itemBuilder: (context, index) { + final isSelected = _selectedAvatarIndex == index; + return GestureDetector( + onTap: () { + setState(() { + _selectedAvatarIndex = index; + }); + }, + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + curve: Curves.easeOut, + transform: isSelected + // ignore: deprecated_member_use + ? (Matrix4.identity()..scale(1.05)) + : Matrix4.identity(), + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: isSelected + ? const Color(0xFF3B82F6) + : Colors.transparent, + width: 3 * scale, + ), + boxShadow: isSelected + ? [ + BoxShadow( + color: const Color( + 0xFF3B82F6, + ).withValues(alpha: 0.3), + blurRadius: 10 * scale, + offset: Offset(0, 4 * scale), + ), + ] + : [], + ), + child: ClipOval( + child: ColorFiltered( + // Applies a grayscale effect to unselected avatars + colorFilter: isSelected + ? const ColorFilter.mode( + Colors.transparent, + BlendMode.multiply, + ) + : const ColorFilter.matrix(greyScaleMatrix), + child: Image.asset( + BaristaPersona.values[index].assetName, + fit: BoxFit.cover, + ), + ), + ), + ), + ); + }, + ), + SizedBox(height: 32 * scale), + + // Submit Button + PrimaryButton( + onPressed: + _usernameController.text.isNotEmpty && + _selectedAvatarIndex != null + ? () { + widget.onSubmit( + _usernameController.text, + BaristaPersona.values[_selectedAvatarIndex!], + ); + } + : null, + child: Center( + child: Padding( + padding: EdgeInsets.all(8 * scale), + child: Text( + 'Get started', + style: TextStyle( + fontSize: 20 * scale, + color: AppColors.white, + fontWeight: FontWeight.w600, + letterSpacing: 0.7, + ), + ), + ), + ), + ), + ], + ), + ); + }, + ), + ); + } +} diff --git a/genlatte/genlatte-ui/lib/src/screens/barista/widgets/humanized_countdown.dart b/genlatte/genlatte-ui/lib/src/screens/barista/widgets/humanized_countdown.dart new file mode 100644 index 0000000..b133e9b --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/screens/barista/widgets/humanized_countdown.dart @@ -0,0 +1,84 @@ +// Copyright 2014 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/material.dart'; + +/// A widget that displays a countdown timer in a human-readable format. +class HumanizedCountdown extends StatefulWidget { + /// Instantiates a [HumanizedCountdown]. + const HumanizedCountdown({ + required this.timestamp, + this.label, + this.textStyle, + super.key, + }); + + /// Optional label. + final String? label; + + /// Start time. + final DateTime timestamp; + + /// Optional styling. + final TextStyle? textStyle; + + @override + State createState() => _HumanizedCountdownState(); +} + +class _HumanizedCountdownState extends State { + late Timer _timer; + + @override + void initState() { + super.initState(); + // Update the UI every second + _timer = Timer.periodic(const Duration(seconds: 1), (timer) { + if (mounted) setState(() {}); + }); + } + + @override + void dispose() { + _timer.cancel(); + super.dispose(); + } + + String _formatDuration(DateTime localizedTime) { + final now = DateTime.now(); + final duration = now.difference(localizedTime); + + // Guard against future dates + if (duration.isNegative || duration.inSeconds < 1) { + return 'Just now'; + } + + final int sec = duration.inSeconds % 60; + final int min = duration.inMinutes % 60; + final int hour = duration.inHours % 24; + final int days = duration.inDays; + + if (days > 0) { + return '${days}d ${hour}h ago'; + } else if (hour > 0) { + return '${hour}h ${min}m ago'; + } else if (min > 0) { + return '${min}m ${sec}s ago'; + } else { + return '${sec}s ago'; + } + } + + @override + Widget build(BuildContext context) { + return Text( + '${widget.label != null ? "${widget.label} " : ''}' + '${_formatDuration(widget.timestamp)}', + style: + widget.textStyle ?? + const TextStyle(fontSize: 16, color: Colors.blueGrey), + ); + } +} diff --git a/genlatte/genlatte-ui/lib/src/screens/barista/widgets/image_stack.dart b/genlatte/genlatte-ui/lib/src/screens/barista/widgets/image_stack.dart new file mode 100644 index 0000000..e7d2551 --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/screens/barista/widgets/image_stack.dart @@ -0,0 +1,101 @@ +// Copyright 2014 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. + +// ignore_for_file: use_if_null_to_convert_nulls_to_bools + +import 'dart:math' show min; + +import 'package:genlatte_data/models.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart' hide IconButton; + +/// Widget to display the generated latte image with the name overlayed. +class ImageStack extends StatelessWidget { + /// Instantiates a [ImageStack] widget. + const ImageStack({required this.scaleFactor, required this.latte, super.key}); + + /// The scale factor to apply to the widget. + final double scaleFactor; + + /// The latte to display. + final Latte latte; + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + return SizedBox.square( + dimension: min( + constraints.maxWidth, + constraints.maxHeight, + ), + child: Stack( + children: [ + Positioned.fill( + child: ClipRRect( + borderRadius: BorderRadius.circular( + 8 * scaleFactor, + ), + child: Stack( + fit: StackFit.expand, + children: [ + Image.network( + latte.metadata.isImageApproved == false + ? 'https://firebasestorage.googleapis.com/v0/b/gcdemos-26-int-dd-latteart.firebasestorage.app/o/latteImages%2FfallbackImage%2Ficon_flutter.png?alt=media&token=06a3ac7e-7929-4507-8105-9eddb4445892' + : latte.metadata.imageUrl!, + fit: BoxFit.cover, + ), + ], + ), + ), + ), + Positioned( + bottom: 0, + left: 0, + right: 0, + child: Container( + padding: EdgeInsets.all(8 * scaleFactor), + color: latte.metadata.isNameApproved != false + ? Colors.black.withValues(alpha: 0.5) + : Colors.red.withValues(alpha: 0.5), + child: Text( + latte.order.name!, + style: TextStyle( + color: Colors.white, + fontSize: 18 * scaleFactor, + ), + ), + // child: Text( + // '${latte.order.name!}' + // '${latte.metadata.isNameApproved == false ? ' ❌' : ''}', + // style: TextStyle( + // color: Colors.white, + // fontSize: 18 * scaleFactor, + // ), + // ), + ), + ), + if (latte.metadata.isImageApproved == false) + Positioned( + top: 0, + right: 0, + left: 0, + child: Container( + padding: EdgeInsets.all(8 * scaleFactor), + color: Colors.red.withValues(alpha: 0.5), + child: Text( + 'Image rejected', + style: TextStyle( + color: Colors.white, + fontSize: 14 * scaleFactor, + ), + ), + ), + ), + ], + ), + ); + }, + ); + } +} diff --git a/genlatte/genlatte-ui/lib/src/screens/barista/widgets/internal_order_queues.dart b/genlatte/genlatte-ui/lib/src/screens/barista/widgets/internal_order_queues.dart new file mode 100644 index 0000000..0a85ff2 --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/screens/barista/widgets/internal_order_queues.dart @@ -0,0 +1,235 @@ +// Copyright 2014 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:genlatte/src/role.dart'; +import 'package:genlatte/src/screens/app/app.dart'; +import 'package:genlatte/src/screens/barista/widgets/widgets.dart'; +import 'package:genlatte/src/widgets/widgets.dart'; +import 'package:genlatte_data/models.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +/// Renders cards for all orders ready for either moderation or brewing. +/// +/// Baristas only see orders ready for brewing, but moderators see both queues +/// to better manage the entire latte bar. +class InternalOrderQueues extends StatelessWidget { + /// Instantiates a [InternalOrderQueues] widget. + const InternalOrderQueues._({ + required this.activeBarista, + required this.canClaimOrders, + required this.baristas, + required this.orders, + required this.role, + + // Moderation callbacks. + required this.onApproveAll, + required this.onRejectName, + required this.onRejectImage, + required this.onRejectBoth, + + // Brewing callbacks. + required this.onClaimPressed, + required this.onCompletePressed, + required this.onReprintPressed, + super.key, + }); + + /// Instantiates a [InternalOrderQueues] widget for a barista. + factory InternalOrderQueues.barista({ + required Barista activeBarista, + required bool canClaimOrders, + required Map baristas, + required List orders, + required void Function(String) onClaimPressed, + required void Function(String) onCompletePressed, + required void Function(String) onReprintPressed, + Key? key, + }) => InternalOrderQueues._( + activeBarista: activeBarista, + baristas: baristas, + canClaimOrders: canClaimOrders, + orders: orders, + role: .barista, + onApproveAll: null, + onRejectName: null, + onRejectImage: null, + onRejectBoth: null, + onClaimPressed: onClaimPressed, + onCompletePressed: onCompletePressed, + onReprintPressed: onReprintPressed, + key: key, + ); + + /// Instantiates a [InternalOrderQueues] widget for a barista. + factory InternalOrderQueues.moderator({ + required Map baristas, + required List orders, + required void Function(String) onApproveAll, + required void Function(String) onRejectName, + required void Function(String) onRejectImage, + required void Function(String) onRejectBoth, + required void Function(String) onCompletePressed, + Key? key, + }) => InternalOrderQueues._( + activeBarista: null, + canClaimOrders: false, + baristas: baristas, + orders: orders, + role: .moderator, + onApproveAll: onApproveAll, + onRejectName: onRejectName, + onRejectImage: onRejectImage, + onRejectBoth: onRejectBoth, + onClaimPressed: null, + onCompletePressed: onCompletePressed, + onReprintPressed: null, + key: key, + ); + + /// Non-null if the user is a barista. + final Barista? activeBarista; + + /// Whether the barista can claim orders. If this is false, the "Claim" button + /// should not be rendered. + /// + /// Irrelevant for moderators, who can never claim orders. + final bool canClaimOrders; + + /// All baristas, used to look up barista info for brew queue. + final Map baristas; + + /// The role of the user viewing the queues. Must be either [Role.barista] or + /// [Role.moderator]. + final Role role; + + /// Orders ready to be displayed. + final List orders; + + /// The barista has approved both the name and image for an order. + final void Function(String)? onApproveAll; + + /// The barista has rejected the name for an order. + final void Function(String)? onRejectName; + + /// The barista has rejected the image for an order. + final void Function(String)? onRejectImage; + + /// The barista has rejected both the name and image for an order. + final void Function(String)? onRejectBoth; + + /// The barista has claimed an order. + final void Function(String)? onClaimPressed; + + /// The barista has completed an order. + final void Function(String) onCompletePressed; + + /// The barista has requested to reprint an order. + final void Function(String)? onReprintPressed; + + static const _whiteText = TextStyle(color: AppColors.white); + + @override + Widget build(BuildContext context) { + return LayoutProvider.builder( + builder: (context, layoutInfo) { + bool hasShownBrewLable = false; + bool hasShownModerationLabel = false; + + Widget getModerationQueueLabelOnce() { + final label = SizedBox( + height: (layoutInfo.height * 0.1).clamp(50, 100), + child: !hasShownModerationLabel + ? Center( + child: const Text( + 'Moderation Queue', + style: InternalOrderQueues._whiteText, + ).h3, + ) + : null, + ); + hasShownModerationLabel = true; + + return label; + } + + Widget getBrewQueueLabelOnce() { + final label = SizedBox( + height: (layoutInfo.height * 0.1).clamp(50, 100), + child: !hasShownBrewLable + ? Center( + child: const Text( + 'Brew Queue', + style: InternalOrderQueues._whiteText, + ).h3, + ) + : null, + ); + hasShownBrewLable = true; + + return label; + } + + return Padding( + padding: EdgeInsets.symmetric(horizontal: layoutInfo.width * 0.05), + child: ListView.separated( + scrollDirection: .horizontal, + itemBuilder: (context, index) { + final latte = orders[index]; + return Column( + crossAxisAlignment: .start, + children: [ + if (latte.metadata.status == LatteOrderStatus.submitted) + getModerationQueueLabelOnce(), + if (latte.metadata.status != LatteOrderStatus.submitted) + getBrewQueueLabelOnce(), + Expanded( + child: + role.isBarista || + latte.metadata.status != LatteOrderStatus.submitted + ? LatteOrderCard.barista( + activeBarista: activeBarista, + canClaimOrders: canClaimOrders, + claimedBy: latte.metadata.baristaId != null + ? baristas[latte.metadata.baristaId] + : null, + latte: latte, + onApproveAll: onApproveAll, + onRejectName: onRejectName, + onRejectImage: onRejectImage, + onRejectBoth: onRejectBoth, + onClaimPressed: onClaimPressed, + onCompletePressed: onCompletePressed, + onReprintPressed: onReprintPressed, + role: role, + key: ValueKey( + '${latte.order.id}--${latte.metadata.status}', + ), + ) + : LatteOrderCard.moderator( + latte: latte, + claimedBy: latte.metadata.baristaId != null + ? baristas[latte.metadata.baristaId] + : null, + onApproveAll: onApproveAll!, + onRejectName: onRejectName!, + onRejectImage: onRejectImage!, + onRejectBoth: onRejectBoth!, + onCompletePressed: onCompletePressed, + key: ValueKey( + '${latte.order.id}--${latte.metadata.status}', + ), + ), + ), + const SizedBox(height: 16), + ], + ); + }, + separatorBuilder: (context, index) => const SizedBox(width: 16), + itemCount: orders.length, + ), + ); + }, + ); + } +} diff --git a/genlatte/genlatte-ui/lib/src/screens/barista/widgets/latte_order_card.dart b/genlatte/genlatte-ui/lib/src/screens/barista/widgets/latte_order_card.dart new file mode 100644 index 0000000..5678056 --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/screens/barista/widgets/latte_order_card.dart @@ -0,0 +1,864 @@ +// Copyright 2014 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:math' show min, pi; + +import 'package:flutter/foundation.dart' show kDebugMode; +import 'package:flutter/material.dart' + show ElevatedButton, IconButton, OutlinedButton; +import 'package:genlatte/src/role.dart'; +import 'package:genlatte/src/screens/app/app.dart'; +import 'package:genlatte/src/screens/barista/widgets/widgets.dart'; +import 'package:genlatte/src/widgets/widgets.dart'; +import 'package:genlatte_data/models.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart' hide IconButton; +import 'package:text_responsive/text_responsive.dart'; + +/// A card representing a single latte order in the queue. +class LatteOrderCard extends StatefulWidget { + /// Instantiates a [LatteOrderCard]. + const LatteOrderCard._({ + required this.activeBarista, + required this.canClaimOrders, + required this.claimedBy, + required this.latte, + required this.role, + + // Moderation callbacks + required this.onApproveAll, + required this.onRejectName, + required this.onRejectImage, + required this.onRejectBoth, + + // Brewing callbacks + required this.onClaimPressed, + required this.onCompletePressed, + required this.onReprintPressed, + + super.key, + }); + + /// Instantiates a [LatteOrderCard] for a barista. + /// + /// [Role] is passed to the barista view of the card because moderators can + /// see all submitted-but-not-completed orders, meaning some moderators will + /// view the barista side of the card; and [role] represents the user's role; + /// not the side of the card currently being shown. + /// + /// This is also why [onClaimPressed] and [onCompletePressed] are nullable; + /// they are not operations that moderators can perform. + factory LatteOrderCard.barista({ + required Barista? activeBarista, + required bool canClaimOrders, + required Barista? claimedBy, + required Latte latte, + required Role role, + required void Function(String)? onApproveAll, + required void Function(String)? onRejectName, + required void Function(String)? onRejectImage, + required void Function(String)? onRejectBoth, + required void Function(String)? onClaimPressed, + required void Function(String) onCompletePressed, + required void Function(String)? onReprintPressed, + Key? key, + }) => LatteOrderCard._( + activeBarista: activeBarista, + canClaimOrders: canClaimOrders, + claimedBy: claimedBy, + latte: latte, + role: role, + onApproveAll: onApproveAll, + onRejectName: onRejectName, + onRejectImage: onRejectImage, + onRejectBoth: onRejectBoth, + onClaimPressed: onClaimPressed, + onCompletePressed: onCompletePressed, + onReprintPressed: onReprintPressed, + key: key, + ); + + /// Instantiates a [LatteOrderCard] for a moderator. + /// + /// The moderator view of a card is more straightforward than its barista + /// counterpart because only moderators ever view cards in the `.submitted` + /// state (which could have been called "needsModeration"). + factory LatteOrderCard.moderator({ + required Latte latte, + required Barista? claimedBy, + required void Function(String) onApproveAll, + required void Function(String) onRejectName, + required void Function(String) onRejectImage, + required void Function(String) onRejectBoth, + required void Function(String) onCompletePressed, + Key? key, + }) => LatteOrderCard._( + activeBarista: null, + canClaimOrders: false, + claimedBy: claimedBy, + latte: latte, + role: .moderator, + onApproveAll: onApproveAll, + onRejectName: onRejectName, + onRejectImage: onRejectImage, + onRejectBoth: onRejectBoth, + onClaimPressed: null, + onCompletePressed: onCompletePressed, + onReprintPressed: null, + key: key, + ); + + /// The order to display. + final Latte latte; + + /// The role of the user viewing the card. Note that this is potentially + /// separate from which side of the card is being displayed; specifically in + /// the instance of a moderator viewing the barista side of the card. + final Role role; + + /// The barista who has claimed the order, if the order is that far along. + final Barista? claimedBy; + + /// Non-null if the user is a barista. + final Barista? activeBarista; + + /// Whether the active barista can claim orders. + /// + /// Irrelevant for moderators. + final bool canClaimOrders; + + /// MODERATION CALLBACKS /// + + /// Callback for when the user approves all. + /// + /// Guaranteed to be non-null when [role] == .moderator. + final void Function(String)? onApproveAll; + + /// Callback for when the user rejects the name. + /// + /// Guaranteed to be non-null when [role] == .moderator. + final void Function(String)? onRejectName; + + /// Callback for when the user rejects the image. + /// + /// Guaranteed to be non-null when [role] == .moderator. + final void Function(String)? onRejectImage; + + /// Callback for when the user rejects both. + /// + /// Guaranteed to be non-null when [role] == .moderator. + final void Function(String)? onRejectBoth; + + /// END MODERATION CALLBACKS /// + + /// + /// + + /// BREWING CALLBACKS /// + + /// Callback for when the claim button is pressed. + final void Function(String)? onClaimPressed; + + /// Callback for when the complete button is pressed. + final void Function(String) onCompletePressed; + + /// Callback for when the reprint button is pressed. + final void Function(String)? onReprintPressed; + + /// END BREWING CALLBACKS /// + + @override + State createState() => _LatteOrderCardState(); +} + +class _LatteOrderCardState extends State + with SingleTickerProviderStateMixin { + bool _isFlipped = false; + late AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + duration: const Duration(milliseconds: 600), + vsync: this, + value: needsModeration ? 0.0 : 1.0, + ); + } + + bool get needsModeration => widget.latte.metadata.status == .submitted; + + void _toggleCard() { + if (_isFlipped) { + _controller.forward().ignore(); + } else { + _controller.reverse().ignore(); + } + setState(() { + _isFlipped = !_isFlipped; + }); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _controller, + builder: (context, child) { + // Calculate the rotation value (0 to pi) + final double rotationValue = _controller.value * pi; + + // Determine which side to show based on the 50% (pi/2) threshold. + // + // Note that this is different from `needsModeration`, which is a fact + // about the state of an order, not a fact about the UI showing that + // order. + final bool showingModerationSide = rotationValue < (pi / 2); + + assert( + () { + if (showingModerationSide) { + if (widget.onApproveAll == null || + widget.onRejectName == null || + widget.onRejectImage == null || + widget.onRejectBoth == null) { + throw Exception( + 'Moderation callbacks are required for moderation side', + ); + } + } + + // If we are not on the moderation side, we are of course on the + // brewing side. But, brewing callbacks are not required because + // moderators have read-only access to the brew queue to gain a full + // picture of the latte bar without actually fulfilling orders + // directly. + + return true; + }(), + 'Attempted to show Moderator side of card from Barista view', + ); + + return Transform( + transform: Matrix4.identity() + ..setEntry(3, 2, 0.001) // Adds perspective/depth + ..rotateY(rotationValue), + alignment: Alignment.center, + // If we are on the back side, we must flip the content + // horizontally so it isn't mirrored. + child: showingModerationSide + ? _LatteOrderCardInner.moderate( + latte: widget.latte, + onApproveAll: () => + widget.onApproveAll!(widget.latte.order.id!), + onRejectName: () => + widget.onRejectName!(widget.latte.order.id!), + onRejectImage: () => + widget.onRejectImage!(widget.latte.order.id!), + onRejectBoth: () => + widget.onRejectBoth!(widget.latte.order.id!), + // Cards *requiring* moderation cannot be flipped to their + // brewing side, as brewing is not available until *after* + // moderation is completed. + onComplete: () => + widget.onCompletePressed(widget.latte.order.id!), + onFlipPressed: widget.role.isModerator ? _toggleCard : null, + ) + : Transform( + alignment: Alignment.center, + transform: Matrix4.identity()..rotateY(pi), + child: _LatteOrderCardInner.brew( + activeBarista: widget.activeBarista, + canClaimOrders: widget.canClaimOrders, + claimedBy: widget.claimedBy, + latte: widget.latte, + onClaimPressed: widget.onClaimPressed != null + ? () => widget.onClaimPressed!(widget.latte.order.id!) + : null, + onCompletePressed: () => + widget.onCompletePressed(widget.latte.order.id!), + onReprintPressed: widget.onReprintPressed != null + ? () => widget.onReprintPressed!(widget.latte.order.id!) + : null, + // Conversely, cards *not* requiring moderation can always + role: widget.role, + // be flipped to their moderation side to change past + // be flipped to their moderation side to change past + // decisions, and then of course back again. + onFlipPressed: widget.role.isModerator ? _toggleCard : null, + ), + ), + ); + }, + ); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } +} + +class _LatteOrderCardInner extends StatelessWidget { + const _LatteOrderCardInner._({ + required this.activeBarista, + required this.canClaimOrders, + required this.claimedBy, + required this.latte, + required this.onApproveAll, + required this.onRejectName, + required this.onRejectImage, + required this.onRejectBoth, + required this.onClaimPressed, + required this.onCompletePressed, + required this.onReprintPressed, + required this.onFlipPressed, + required this.role, + }); + + /// Creates the moderation side of the card. + factory _LatteOrderCardInner.moderate({ + required Latte latte, + required VoidCallback onApproveAll, + required VoidCallback onRejectName, + required VoidCallback onRejectImage, + required VoidCallback onRejectBoth, + required VoidCallback? onFlipPressed, + required VoidCallback onComplete, + }) => _LatteOrderCardInner._( + activeBarista: null, + canClaimOrders: false, + claimedBy: null, + latte: latte, + onApproveAll: onApproveAll, + onRejectName: onRejectName, + onRejectImage: onRejectImage, + onRejectBoth: onRejectBoth, + onClaimPressed: null, + onCompletePressed: onComplete, + onReprintPressed: null, + onFlipPressed: onFlipPressed, + role: .moderator, + ); + + /// Creates the brewing side of the card. + factory _LatteOrderCardInner.brew({ + required Barista? activeBarista, + required bool canClaimOrders, + required Barista? claimedBy, + required Latte latte, + required VoidCallback? onClaimPressed, + required VoidCallback onCompletePressed, + required VoidCallback? onReprintPressed, + required VoidCallback? onFlipPressed, + required Role role, + }) => _LatteOrderCardInner._( + activeBarista: activeBarista, + canClaimOrders: canClaimOrders, + claimedBy: claimedBy, + latte: latte, + onApproveAll: null, + onRejectName: null, + onRejectImage: null, + onRejectBoth: null, + onClaimPressed: onClaimPressed, + onCompletePressed: onCompletePressed, + onReprintPressed: onReprintPressed, + onFlipPressed: onFlipPressed, + role: role, + ); + + final Latte latte; + final Barista? claimedBy; + final bool canClaimOrders; + + /// Non-null if the user is a barista. + final Barista? activeBarista; + + // Moderation callbacks. + + final VoidCallback? onApproveAll; + final VoidCallback? onRejectName; + final VoidCallback? onRejectImage; + final VoidCallback? onRejectBoth; + + // Brewing callbacks. + + final VoidCallback? onClaimPressed; + final VoidCallback onCompletePressed; + final VoidCallback? onReprintPressed; + final Role role; + + // UI callbacks. + + final VoidCallback? onFlipPressed; + + bool get _isActive => + _isMe && latte.metadata.status == LatteOrderStatus.inProgress; + + bool get _isMe => + activeBarista != null && + claimedBy != null && + activeBarista?.id == claimedBy?.id; + + /// Arbitrary numbers picked by fair die roll. + static const _idealSize = Size(230, 420); + + @override + Widget build(BuildContext context) { + return ResponsiveSizedBox.builder( + aspectRatioClamp: (null, _idealSize.aspectRatio, null), + builder: (context, size) { + final heightScaleFactor = size.height / _idealSize.height; + final scaleFactor = min( + size.width / _idealSize.width, + heightScaleFactor, + ); + + final padding = 20 * scaleFactor; + + final bool isActive = _isActive; + + return Container( + width: size.width, + height: size.height, + padding: EdgeInsets.all(padding), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16 * scaleFactor), + border: isActive + ? Border.all( + color: const Color(0xFF3B82F6), + width: 8 * scaleFactor, + ) + : null, + boxShadow: [ + BoxShadow( + color: isActive + ? const Color(0xFF3B82F6).withValues(alpha: 0.25) + : Colors.black.withValues(alpha: 0.08), + blurRadius: isActive ? 40 * scaleFactor : 25 * scaleFactor, + spreadRadius: isActive ? 4 * scaleFactor : 0, + offset: Offset(0, 10 * scaleFactor), + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: .stretch, + children: [ + // Context Header + Expanded( + child: Row( + mainAxisAlignment: .spaceBetween, + children: [ + Text( + 'Order #${latte.metadata.orderNumber}', + style: TextStyle( + fontSize: 11 * scaleFactor, + color: const Color(0xFF444444), + fontWeight: FontWeight.w500, + ), + ), + HumanizedCountdown( + label: 'Submitted', + timestamp: latte.metadata.orderSubmittedTime!, + textStyle: TextStyle( + fontSize: 11 * scaleFactor, + color: const Color(0xFF444444), + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + + Expanded( + flex: 3, + child: ImageStack(latte: latte, scaleFactor: scaleFactor), + ), + + SizedBox(height: 16 * scaleFactor), + + // Action Buttons + Expanded( + flex: 4, + child: Column( + crossAxisAlignment: .stretch, + children: onApproveAll != null + ? _buildModerationButtons( + scaleFactor, + heightScaleFactor, + ) + : _buildBrewButtons(scaleFactor, canClaimOrders), + ), + ), + Expanded( + child: Row( + children: [ + Expanded( + flex: 5, + child: kDebugMode || role == .moderator + ? ClipboardValue( + '${latte.order.id}', + label: 'Order Id', + ) + : const SizedBox.shrink(), + ), + const Spacer(), + if (onFlipPressed != null && + // Don't show flip button on submitted orders. Order + // cards are only flippable *after* moderation is + // complete. + latte.metadata.status != .submitted) + IconButton( + onPressed: onFlipPressed, + icon: const Icon(Icons.flip), + ), + if (onFlipPressed == null) // + const SizedBox(height: 50), + ], + ), + ), + ], + ), + ); + }, + ); + } + + List _buildBrewButtons(double scaleFactor, bool canClaimOrders) { + return [ + Row( + children: [ + Expanded(child: _buildStatusBadge(scaleFactor)), + const SizedBox(width: 8), + Expanded( + child: _buildButtonOrAssigneBanner(scaleFactor, canClaimOrders), + ), + ], + ), + SizedBox(height: 16 * scaleFactor), + OrderDetailsWidget( + scaleFactor: scaleFactor, + details: [ + OrderDetailItem( + icon: const Icon(Icons.local_drink), + label: 'Milk:', + value: latte.order.milk!, + ), + OrderDetailItem( + icon: const Icon(Icons.cookie), + label: 'Sweetener:', + value: latte.order.sweetener!, + ), + + /// Always the same for Cloud Next, but will vary at I/O and beyond. + OrderDetailItem( + icon: const Icon(Icons.coffee), + label: 'Drink type:', + value: 'Latte', + ), + ], + ), + SizedBox(height: 16 * scaleFactor), + ]; + } + + List _buildModerationButtons( + double scaleFactor, + double heightScaleFactor, + ) => [ + _buildModerateButton( + label: '✓ Approve All', + shortcut: 'A', + backgroundColor: const Color(0xFF10B981), + textColor: Colors.white, + shortcutColor: Colors.white.withValues(alpha: 0.3), + onPressed: onApproveAll!, + scaleFactor: scaleFactor, + heightScaleFactor: heightScaleFactor, + ), + SizedBox(height: 12 * scaleFactor), + Row( + children: [ + Expanded( + child: _buildModerateButton( + label: '⚠️ Reject Name', + shortcut: 'N', + backgroundColor: const Color(0xFFFFFBEB), + textColor: const Color(0xFFD97706), + borderColor: const Color(0xFFFCD34D), + shortcutColor: Colors.black.withValues( + alpha: 0.05, + ), + onPressed: onRejectName!, + scaleFactor: scaleFactor, + heightScaleFactor: heightScaleFactor, + ), + ), + SizedBox(width: 12 * scaleFactor), + Expanded( + child: _buildModerateButton( + label: '⚠️ Reject Image', + shortcut: 'I', + backgroundColor: const Color(0xFFFFFBEB), + textColor: const Color(0xFFD97706), + borderColor: const Color(0xFFFCD34D), + shortcutColor: Colors.black.withValues( + alpha: 0.05, + ), + onPressed: onRejectImage!, + scaleFactor: scaleFactor, + heightScaleFactor: heightScaleFactor, + ), + ), + ], + ), + SizedBox(height: 12 * scaleFactor), + _buildModerateButton( + label: '❌ Reject Both', + shortcut: 'R', + backgroundColor: const Color(0xFFFEE2E2), + textColor: const Color(0xFFDC2626), + borderColor: const Color(0xFFFECACA), + shortcutColor: Colors.black.withValues(alpha: 0.05), + onPressed: onRejectBoth!, + scaleFactor: scaleFactor, + heightScaleFactor: heightScaleFactor, + ), + ]; + + Widget _buildButtonOrAssigneBanner(double scaleFactor, bool canClaimOrders) { + if (claimedBy == null && onClaimPressed != null) { + if (!canClaimOrders) { + return const Text( + 'Choose a Printer', + textAlign: .end, + style: TextStyle(color: AppColors.googleIntroRed), + ).bold; + } + return ElevatedButton( + onPressed: onClaimPressed, + style: ElevatedButton.styleFrom( + // minimumSize: Size.zero, + backgroundColor: const Color(0xFF3B82F6), + foregroundColor: Colors.white, + // elevation: 0, + padding: EdgeInsets.symmetric(vertical: 6 * scaleFactor), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + child: Text( + 'Claim Order', + style: TextStyle( + fontSize: 12 * scaleFactor, + ), + ), + ); + } else if (_isMe) { + return ElevatedButton( + onPressed: onCompletePressed, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF10B981), + foregroundColor: Colors.white, + padding: EdgeInsets.symmetric(vertical: 6 * scaleFactor), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + child: Text( + 'Complete', + style: TextStyle( + fontSize: 12 * scaleFactor, + ), + ), + ); + } else if (role.isModerator && + latte.metadata.status == LatteOrderStatus.inProgress) { + return ElevatedButton( + onPressed: onCompletePressed, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF10B981), + foregroundColor: Colors.white, + padding: EdgeInsets.symmetric(vertical: 6 * scaleFactor), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + child: Text( + 'Complete', + style: TextStyle( + fontSize: 12 * scaleFactor, + ), + ), + ); + } else { + return const SizedBox.shrink(); + } + } + + Widget _buildStatusBadge(double scaleFactor) { + if (_isActive && onReprintPressed != null) { + return _ReprintButton( + onReprintPressed: onReprintPressed!, + scaleFactor: scaleFactor, + ); + } else if (_isActive) { + return const SizedBox.shrink(); + } + + return Container( + padding: EdgeInsets.symmetric( + horizontal: 14 * scaleFactor, + vertical: 4 * scaleFactor, + ), + decoration: BoxDecoration( + color: claimedBy != null + ? const Color(0xFFF3E8FF) + : const Color(0xFFE0F2FE), + borderRadius: BorderRadius.circular(20 * scaleFactor), + ), + child: ParagraphTextWidget( + claimedBy != null ? claimedBy!.username : 'PENDING', + maxLines: 1, + textAlign: .center, + style: TextStyle( + fontSize: 10 * scaleFactor, + fontWeight: FontWeight.bold, + letterSpacing: 0.5, + color: claimedBy != null + ? const Color(0xFF7E22CE) + : const Color(0xFF0369A1), + ), + ), + ); + } + + Widget _buildAssigneeBanner(double scaleFactor) { + // Claimed State Banner + return Container( + padding: EdgeInsets.symmetric( + horizontal: 3 * scaleFactor, + vertical: 3 * scaleFactor, + ), + decoration: BoxDecoration( + color: const Color(0xFFF8FAFC), + borderRadius: BorderRadius.circular(8 * scaleFactor), + border: Border.all(color: const Color(0xFFE2E8F0)), + ), + child: Center( + child: ParagraphTextWidget( + claimedBy!.username, + style: const TextStyle(fontWeight: FontWeight.bold), + maxLines: 1, + ), + ), + ); + } + + // Helper method to keep button styling consistent and code DRY + Widget _buildModerateButton({ + required String label, + required String shortcut, + required Color backgroundColor, + required Color textColor, + required Color shortcutColor, + required VoidCallback onPressed, + required double scaleFactor, + required double heightScaleFactor, + Color? borderColor, + }) { + return ElevatedButton( + onPressed: onPressed, + style: ElevatedButton.styleFrom( + minimumSize: Size.zero, + backgroundColor: backgroundColor, + foregroundColor: textColor, + elevation: 0, + padding: EdgeInsets.symmetric(vertical: 12 * heightScaleFactor), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8 * scaleFactor), + side: borderColor != null + ? BorderSide(color: borderColor) + : BorderSide.none, + ), + ), + child: Row( + mainAxisAlignment: .center, + children: [ + Text( + label, + style: TextStyle( + fontSize: 10 * scaleFactor, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ); + } +} + +class _ReprintButton extends StatefulWidget { + const _ReprintButton({ + required this.onReprintPressed, + required this.scaleFactor, + }); + + final VoidCallback onReprintPressed; + final double scaleFactor; + + @override + State<_ReprintButton> createState() => _ReprintButtonState(); +} + +class _ReprintButtonState extends State<_ReprintButton> { + bool _isPrinting = false; + + Future _handlePress() async { + if (_isPrinting) return; + + setState(() => _isPrinting = true); + widget.onReprintPressed(); + + await Future.delayed(const Duration(seconds: 3)); + + if (mounted) { + setState(() => _isPrinting = false); + } + } + + @override + Widget build(BuildContext context) { + return OutlinedButton( + onPressed: _isPrinting ? null : _handlePress, + style: OutlinedButton.styleFrom( + foregroundColor: const Color(0xFF3B82F6), + disabledForegroundColor: const Color(0xFF3B82F6).withValues(alpha: 0.5), + side: BorderSide( + color: _isPrinting + ? const Color(0xFF3B82F6).withValues(alpha: 0.5) + : const Color(0xFF3B82F6), + ), + padding: EdgeInsets.symmetric(vertical: 6 * widget.scaleFactor), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + child: _isPrinting + ? SizedBox( + width: 14 * widget.scaleFactor, + height: 14 * widget.scaleFactor, + child: const CircularProgressIndicator(), + ) + : Text( + '⬆️ Printer', + style: TextStyle( + fontSize: 10 * widget.scaleFactor, + ), + ), + ); + } +} diff --git a/genlatte/genlatte-ui/lib/src/screens/barista/widgets/order_details_table.dart b/genlatte/genlatte-ui/lib/src/screens/barista/widgets/order_details_table.dart new file mode 100644 index 0000000..07d30ac --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/screens/barista/widgets/order_details_table.dart @@ -0,0 +1,122 @@ +// Copyright 2014 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:text_responsive/text_responsive.dart'; + +/// Simple model for the data to display. +class OrderDetailItem { + /// Instantiates a [OrderDetailItem]. + OrderDetailItem({ + required this.icon, + required this.label, + required this.value, + }); + + /// Leading visual indicator of what the row is about. + final Widget icon; + + /// Plain text label. + final String label; + + /// Configuration value for the label. + final String value; +} + +/// Displays a list of [OrderDetailItem]s in a table. +class OrderDetailsWidget extends StatelessWidget { + /// Instantiates an [OrderDetailsWidget]. + const OrderDetailsWidget({ + required this.details, + this.scaleFactor = 1.0, + super.key, + }); + + /// The details to display. + final List details; + + /// The factor by which to scale the UI. + final double scaleFactor; + + @override + Widget build(BuildContext context) { + if (details.isEmpty) return const SizedBox.shrink(); + + return Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12 * scaleFactor), + border: Border.all(color: Colors.grey.shade200), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.03), + blurRadius: 4 * scaleFactor, + offset: Offset(0, 2 * scaleFactor), + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, // Hugs the content tightly + children: details.asMap().entries.map((entry) { + final index = entry.key; + final item = entry.value; + final isLast = index == details.length - 1; + + return Column( + children: [ + Padding( + padding: EdgeInsets.all(10 * scaleFactor), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + // Left Side: Icon and Label + Row( + children: [ + SizedBox( + width: 24 * scaleFactor, + child: Center( + child: Transform.scale( + scale: scaleFactor, + child: item.icon, + ), + ), + ), + SizedBox(width: 12 * scaleFactor), + ParagraphTextWidget( + item.label, + style: TextStyle( + fontSize: 12 * scaleFactor, + fontWeight: FontWeight.w500, + color: Colors.grey.shade800, + ), + ), + ], + ), + + // Right Side: Value + ParagraphTextWidget( + item.value, + style: TextStyle( + fontSize: 16 * scaleFactor, + fontWeight: FontWeight.bold, + color: Colors.black87, + ), + ), + ], + ), + ), + // Smart Divider: Only show if it's NOT the last item + if (!isLast) + Divider( + height: 1 * scaleFactor, + thickness: 1 * scaleFactor, + color: Colors.grey.shade200, + ), + ], + ); + }).toList(), + ), + ); + } +} diff --git a/genlatte/genlatte-ui/lib/src/screens/barista/widgets/widgets.dart b/genlatte/genlatte-ui/lib/src/screens/barista/widgets/widgets.dart new file mode 100644 index 0000000..71d8dcd --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/screens/barista/widgets/widgets.dart @@ -0,0 +1,10 @@ +// Copyright 2014 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 'barista_persona_card.dart'; +export 'humanized_countdown.dart'; +export 'image_stack.dart'; +export 'internal_order_queues.dart'; +export 'latte_order_card.dart'; +export 'order_details_table.dart'; diff --git a/genlatte/genlatte-ui/lib/src/screens/kiosk/CONTEXT.md b/genlatte/genlatte-ui/lib/src/screens/kiosk/CONTEXT.md new file mode 100644 index 0000000..04df99c --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/screens/kiosk/CONTEXT.md @@ -0,0 +1,11 @@ +# Kiosk / Barista / Moderator / Queue / Recent Orders Roots + +**Purpose:** +This directory acts as the architectural boundary grouping for the application's distinct personas. + +**Implementation Details:** +- **Barrel Architecture**: This level strictly exports child module directories like `home/` and `widgets/` via an `index` / `barrel` file. +- **Child Contexts**: Please navigate into `home/` or `widgets/` subdirectories to view specific BLoC architectures, View layouts, and complex logic files tailored towards each app persona. + +**Dependencies:** +- Consumed by `core/routing/routes.dart` to map physical URLs to specific screen entry points. diff --git a/genlatte/genlatte-ui/lib/src/screens/kiosk/home/CONTEXT.md b/genlatte/genlatte-ui/lib/src/screens/kiosk/home/CONTEXT.md new file mode 100644 index 0000000..25b9d58 --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/screens/kiosk/home/CONTEXT.md @@ -0,0 +1,25 @@ +# Kiosk Home + +**Purpose:** +Manages the application state, wizard-style navigation, and primary user interface for the consumer-facing Kiosk app. It orchestrates the entire order flow—from entering a name and selecting drink options to submitting a "happy place" and interacting with generated AI imagery. + +**Detailed File Overviews:** + +- `kiosk_home.dart`: + - **Description**: A barrel file. + - **Usage/Exports**: Re-exports the Bloc (`kiosk_home_bloc.dart`) and the View (`kiosk_home_view.dart`). + +- `kiosk_home_bloc.dart`: + - **Description**: The massive, central state management engine for the Kiosk screen wizard. + - **Core Logic**: Manages a complex step-by-step wizard state (`KioskWizardStep` enum), handling forward/backward navigation and validations. It coordinates heavy asynchronous tasks like creating a `LatteOrder` in Firestore, watching for image generation streams (`_watchImageBatch`), processing moderation events (e.g. `HappyPlaceRejected`), submitting AI image tweaks via follow-up questions, and ultimately finalizing an order via `_onSubmitOrder`. + - **Exposed Methods**: Consumes numerous `KioskHomeEvent` variants (e.g., `StartOver`, `SubmitHappyPlace`, `GenerateRevisedImages`, `AcceptImage`). Emits updated `KioskHomeState` objects representing the user's progress through the ordering flow. + +- `kiosk_home_view.dart`: + - **Description**: The UI orchestration layer for the Kiosk. + - **Core Logic**: Wraps the wizard in a `ZipPageView`, binding `KioskHomeBloc` states directly to individual wizard sub-screens (e.g., `KioskDrinkOrderName`, `KioskHappyPlace`, `KioskTweakImage`) located in the `/steps` subdirectory. Also manages top-level error toasts (like happy place moderation rejections) and layout structure (headers/footers, segmented progress bar). + - **Dependencies**: Imports and renders all specific step components from `kiosk/home/steps/steps.dart`. + +**Dependencies/Relationships:** +- Heaps of interaction with `genlatte_data` (e.g. `LatteOrdersRepository`, `Repository`). +- Directly delegates to individual UI fragments within the `steps/` subfolder. +- Consumes components from `kiosk/widgets/` like `SegmentedProgress`, `ZipPageView`. diff --git a/genlatte/genlatte-ui/lib/src/screens/kiosk/home/kiosk_home.dart b/genlatte/genlatte-ui/lib/src/screens/kiosk/home/kiosk_home.dart new file mode 100644 index 0000000..0d7f300 --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/screens/kiosk/home/kiosk_home.dart @@ -0,0 +1,6 @@ +// Copyright 2014 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 'kiosk_home_bloc.dart'; +export 'kiosk_home_view.dart'; diff --git a/genlatte/genlatte-ui/lib/src/screens/kiosk/home/kiosk_home_bloc.dart b/genlatte/genlatte-ui/lib/src/screens/kiosk/home/kiosk_home_bloc.dart new file mode 100644 index 0000000..abe430f --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/screens/kiosk/home/kiosk_home_bloc.dart @@ -0,0 +1,1094 @@ +// Copyright 2014 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. + +// ignore_for_file: use_if_null_to_convert_nulls_to_bools + +import 'dart:async'; + +import 'package:data_layer/data_layer.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:genlatte/src/data/data.dart'; +import 'package:genlatte_data/models.dart'; +import 'package:get_it/get_it.dart'; +import 'package:logging/logging.dart'; +import 'package:uuid/uuid.dart'; + +part 'kiosk_home_bloc.freezed.dart'; + +final _logger = Logger('KioskHomeBloc'); + +typedef _Emit = Emitter; + +/// State manager for the Kiosk home page. +class KioskHomeBloc extends Bloc { + /// {@macro KioskHomeBloc} + KioskHomeBloc() : super(KioskHomeState.initial()) { + on((event, _Emit emit) { + _logger.finer('Kiosk event: $event'); + return switch (event) { + OnNewPage() => _onNewPage(event, emit), + StartOver() => _onStartOver(event, emit), + _EjectData() => _onEjectData(event, emit), + LoadPreExistingOrders() => _onLoadPreExistingOrders(event, emit), + ApplyPreExistingOrder() => _onApplyPreExistingOrder(event, emit), + UpdateMilkOptions() => _onUpdateMilkOptions(event, emit), + UpdateSweetenerOptions() => _onUpdateSweetenerOptions(event, emit), + GoBackKioskWizard() => _onGoBackKioskWizard(event, emit), + GoToStep() => _onGoToStep(event, emit), + SubmitUserName() => _onSubmitUserName(event, emit), + SelectMilk() => _onSelectMilk(event, emit), + SelectSweetener() => _onSelectSweetener(event, emit), + SubmitMilkAndSweetener() => _onSubmitMilkAndSweetener(event, emit), + SubmitHappyPlace() => _onSubmitHappyPlace(event, emit), + HappyPlaceRejected() => _onHappyPlaceRejected(event, emit), + HappyPlaceModerationReasonShown() => _onHappyPlaceModerationReasonShown( + event, + emit, + ), + ServerOrderUpdate() => _onServerOrderUpdate(event, emit), + ServerMetadataUpdate() => _onServerMetadataUpdate(event, emit), + UpdateLatteImages() => _onUpdateLatteImages(event, emit), + NewImageLatteBatch() => _onNewImageLatteBatch(event, emit), + SelectImage() => _onSelectImage(event, emit), + UpdateQuestions() => _onUpdateQuestions(event, emit), + AnswerQuestion() => _onAnswerQuestion(event, emit), + GenerateRevisedImages() => _onGenerateRevisedImages(event, emit), + RejectImageBatch() => _onRejectImageBatch(event, emit), + AcceptImage() => _onAcceptImage(event, emit), + SubmitOrder() => _onSubmitOrder(event, emit), + }; + }); + + _orderRepository = GetIt.I(); + _metadataRepository = GetIt.I>(); + _imagesRepository = GetIt.I>(); + _optionsRepository = GetIt.I>(); + + _orderRepository + .getItems( + details: RequestDetails.read( + requestType: .allLocal, + ), + ) + .then((orders) { + add(LoadPreExistingOrders(orders)); + }) + .ignore(); + + _loadForStep(state.currentStep, null); + _loadMilksAndSweeteners(); + } + + late final LatteOrdersRepository _orderRepository; + late final Repository _metadataRepository; + late final Repository _imagesRepository; + late final Repository _optionsRepository; + + StreamSubscription? _milkOptionsSubscription; + StreamSubscription? _sweetenerOptionsSubscription; + StreamSubscription? _orderSubscription; + StreamSubscription? _metadataSubscription; + StreamSubscription? _imageBatchSubscription; + + void _loadMilksAndSweeteners() { + final milkOptionsStream = _optionsRepository.watch('milks'); + final sweetenerOptionsStream = _optionsRepository.watch('sweeteners'); + + _milkOptionsSubscription ??= milkOptionsStream.listen( + (milkOptions) { + add(UpdateMilkOptions(milkOptions?.values)); + }, + ); + _sweetenerOptionsSubscription ??= sweetenerOptionsStream.listen( + (sweetenerOptions) { + add(UpdateSweetenerOptions(sweetenerOptions?.values)); + }, + ); + } + + Future _onStartOver(StartOver event, _Emit emit) async { + _logger.fine('_onStartOver() called'); + emit(state.copyWith(shouldClearData: true)); + _goToStep(.intro, emit); + } + + Future _onEjectData(_EjectData event, _Emit emit) async { + _logger.fine('_onEjectData() called'); + + // // Cancel any existing subscriptions + await _orderSubscription?.cancel(); + await _metadataSubscription?.cancel(); + await _imageBatchSubscription?.cancel(); + + // // Delete the order from local memory + await _orderRepository.delete( + state.order!.id!, + RequestDetails.write(requestType: .local), + ); + + // // Reset the state + emit( + KioskHomeState.initial().copyWith( + milkOptions: state.milkOptions, + sweetenerOptions: state.sweetenerOptions, + ), + ); + } + + Future _onLoadPreExistingOrders( + LoadPreExistingOrders event, + _Emit emit, + ) async { + _logger.fine('_onLoadPreExistingOrders() called'); + + if (event.orders.isEmpty) { + // No problem, but also nothing to do + return; + } + if (event.orders.length > 1) { + // There should only ever be one order at a time for the kiosk. + // If there are more than one, then something is wrong. + // If there are none, then we should start from the beginning. + final deleteFutures = >[]; + for (final order in event.orders) { + deleteFutures.add( + _orderRepository.delete( + order.id!, + RequestDetails.write(requestType: .local), + ), + ); + } + await Future.wait(deleteFutures); + return; + } + + final order = event.orders.first; + + // Load the metadata for the order; locally first, or from Firebase if we + // do not get a cache hit locally. + final metadata = await _metadataRepository.getById(order.id!); + + if (metadata == null) { + // A cached order survived. Un-good. + // Delete it and start over. + await _orderRepository.delete( + order.id!, + RequestDetails.write(requestType: .local), + ); + return; + } + add(ApplyPreExistingOrder(order, metadata)); + } + + Future _onApplyPreExistingOrder( + ApplyPreExistingOrder event, + _Emit emit, + ) async { + _logger.fine('_onApplyPreExistingOrder() called with order ${event.order}'); + + late KioskWizardStep stepToJumpTo; + + if (event.metadata.status == .submitted) { + add(const StartOver()); + return; + } + + _watchOrder(event.order); + + if (event.metadata.imageBatchId != null) { + _watchImageBatch(event.metadata.imageBatchId!); + } + + if (event.order.name == null) { + stepToJumpTo = .name; + } else if (event.order.milk == null || event.order.sweetener == null) { + stepToJumpTo = .milkAndSweetener; + } else if (event.order.happyPlace == null || + event.order.happyPlace!.isEmpty) { + stepToJumpTo = .happyPlace; + } else if (event.metadata.imageUrl != null) { + stepToJumpTo = .submitOrder; + } + // From here on down, the user could have fallen into odd cracks, like + // having submitted their happy place but not having received notice of the + // resulting [LatteImageBatch] + else { + if (event.metadata.imageBatchId == null) { + // Let the user re-submit this and resume. + stepToJumpTo = .happyPlace; + } else { + // We are already watching the image batch Id, but we also need to load + // that value immediately to figure out whether the user had made it all + // the way to image tweaking, or just original image selection. + final imageBatch = await _imagesRepository.getById( + event.metadata.imageBatchId!, + ); + if (imageBatch == null || imageBatch.parent == null) { + stepToJumpTo = .chooseAnImage; + } else { + stepToJumpTo = .chooseATweakedImage; + } + } + } + + emit( + state.copyWith( + order: event.order, + metadata: event.metadata, + currentStep: stepToJumpTo, + ), + ); + } + + void _onUpdateMilkOptions(UpdateMilkOptions event, _Emit emit) { + _logger.fine( + '_onUpdateMilkOptions() called ' + 'with [${event.milkOptions?.join(', ')}]', + ); + emit(state.copyWith(milkOptions: event.milkOptions)); + } + + void _onUpdateSweetenerOptions(UpdateSweetenerOptions event, _Emit emit) { + _logger.fine( + '_onUpdateSweetenerOptions() called ' + 'with [${event.sweetenerOptions?.join(', ')}]', + ); + emit(state.copyWith(sweetenerOptions: event.sweetenerOptions)); + } + + Future _onGoBackKioskWizard(GoBackKioskWizard event, _Emit emit) async { + _logger.fine( + '_onGoBackKioskWizard() called from step .${state.currentStep.name}', + ); + + // First, handle special cases; then fall back to default logic of + // navigating to the N-1 step. + + // First special case: going back from the Accept Order screen + if (state.currentStep == .submitOrder) { + final KioskWizardStep stepToGoBackTo = state.imagesBatch!.parent != null + // If the parent is set, then the person ultimately choose a + ? .chooseATweakedImage + : .chooseAnImage; + return add(GoToStep(stepToGoBackTo)); + } else if (state.currentStep == .chooseATweakedImage) { + // A special `isSubmitting` update to lock down the forward button while + // the server switches around the Order's image batch and we prepare to + // navigate backwards. This will be cleared upon arriving at the new + // wizard step. + emit(state.copyWith(isSubmitting: true)); + + // And now reject. + return add(const RejectImageBatch()); + } + + final previousStep = state.currentStep.previousStep; + if (previousStep == null) { + return; + } + add(GoToStep(previousStep)); + } + + Future _onGoToStep(GoToStep event, _Emit emit) async { + _logger.fine('Explicitly navigating to .${event.step.name}'); + _goToStep(event.step, emit); + } + + void _goToStep(KioskWizardStep step, _Emit emit) { + _logger.fine('_goToStep() called with .${step.name}'); + emit(state.copyWith(currentStep: step)); + _loadForStep(step, emit); + } + + Future _onSubmitUserName(SubmitUserName event, _Emit emit) async { + _logger.fine('_onSubmitUserName() called with name: "${event.name}"'); + + if (event.name.isEmpty) { + return; + } + + if (event.name == state.order?.name) { + _goToStep(.milkAndSweetener, emit); + return; + } + + emit(state.copyWith(isSubmitting: true)); + final order = + // The Order may already exist if moderation kicked the user back + // to this step. + state.order?.copyWith(name: event.name) ?? + // But if it does not already exist, which is the most common + // scenario, then create one. + LatteOrder(name: event.name); + + final savedOrder = await _orderRepository.setItem(order); + emit(state.copyWith(order: savedOrder)); + _watchOrder(savedOrder); + add(const GoToStep(.milkAndSweetener)); + } + + Future _onSelectMilk(SelectMilk event, _Emit emit) async { + _logger.fine('_onSelectMilk() called with milk: "${event.milk}"'); + assert(state.order != null, 'Order must exist when selecting milk'); + emit(state.copyWith(order: state.order!.copyWith(milk: event.milk))); + } + + Future _onSelectSweetener(SelectSweetener event, _Emit emit) async { + _logger.fine( + '_onSelectSweetener() called with sweetener: "${event.sweetener}"', + ); + assert(state.order != null, 'Order must exist when selecting sweetener'); + emit( + state.copyWith(order: state.order!.copyWith(sweetener: event.sweetener)), + ); + } + + Future _onSubmitMilkAndSweetener( + SubmitMilkAndSweetener event, + _Emit emit, + ) async { + _logger.fine('_onSubmitMilkAndSweetener() called'); + assert( + state.order != null, + 'Order must exist when submitting milk and sweetener', + ); + emit(state.copyWith(isSubmitting: true)); + final savedOrder = await _orderRepository.setItem(state.order!); + emit(state.copyWith(order: savedOrder)); + add(const GoToStep(.happyPlace)); + } + + Future _onSubmitHappyPlace(SubmitHappyPlace event, _Emit emit) async { + _logger.fine( + '_onSubmitHappyPlace() called with happy place: "${event.happyPlace}"', + ); + assert(state.order != null, 'Order must exist when submitting happy place'); + + // Check whether we're passing through again with an already-moderated + // happy place. + final isSameHappyPlace = + state.metadata!.isHappyPlaceApproved == true && + event.happyPlace.isNotEmpty && + state.order!.happyPlace == event.happyPlace; + + if (!isSameHappyPlace) { + emit(state.copyWith(isSubmitting: true)); + _imageBatchSubscription?.cancel().ignore(); + final order = state.order!.copyWith(happyPlace: event.happyPlace); + emit( + state.copyWith( + imagesBatch: null, + selectedImageIndex: null, + imageGenerationStartTime: DateTime.now(), + ), + ); + final savedOrder = await _orderRepository.setItem(order); + emit(state.copyWith(order: savedOrder)); + } + + return add(const GoToStep(.chooseAnImage)); + } + + void _onHappyPlaceRejected(HappyPlaceRejected event, _Emit emit) { + _logger.info('Happy place rejected for reason: "${event.reason}"'); + emit( + state.copyWith( + order: state.order?.copyWith(happyPlace: null), + happyPlaceModerationEvent: ModerationEvent(event.reason), + ), + ); + add(const GoToStep(.happyPlace)); + } + + void _onHappyPlaceModerationReasonShown( + HappyPlaceModerationReasonShown event, + _Emit emit, + ) { + _logger.finer('_onHappyPlaceModerationReasonShown() called'); + emit(state.copyWith(happyPlaceModerationEvent: null)); + } + + /// Opens a stream with Firebase to watch for changes to the [LatteOrder]. + void _watchOrder(LatteOrder? order) { + _logger.fine('_watchOrder() called for order ${order?.id}'); + _orderSubscription?.cancel().ignore(); + _metadataSubscription?.cancel().ignore(); + if (order != null) { + _logger.info('Watching Order Id ${order.id}'); + final orderStream = _orderRepository.watch(order.id!); + _orderSubscription = orderStream.listen(_onNewOrderFromServer); + + // Order and Metadata share the same Id + final metadataStream = _metadataRepository.watch(order.id!); + _metadataSubscription = metadataStream.listen(_onNewMetadataFromServer); + } + } + + Future _onNewOrderFromServer(LatteOrder? order) async { + LatteOrder? serverOrder = order; + _logger.finer('New LatteOrder from server: $serverOrder'); + + if (serverOrder != null && state.currentStep == .milkAndSweetener) { + // Catch a potential race condition where users progressing quickly from + // the .name step and into the .milkAndSweetener step and immediately + // setting a milk or sweetener value can sometimes have that value + // overridden by the server if the initial value from the LatteOrder watch + // stream is slow, and specifically, arrives after the user has already + // begun modifying local state. + // + // There are no other known scenarios where this race condition can occur + // because it is dependent on the relationship between initializing the + // stream and then modifying local state before the first payload, which + // is only known to be possible in this specific scenario. + if (state.order?.milk != null && serverOrder.milk == null) { + serverOrder = serverOrder.copyWith(milk: state.order!.milk); + } + if (state.order?.sweetener != null && serverOrder.sweetener == null) { + serverOrder = serverOrder.copyWith(sweetener: state.order!.sweetener); + } + } + + add(ServerOrderUpdate(serverOrder)); + if (order == null) { + _onNewMetadataFromServer(null).ignore(); + } + } + + /// Handler for when [LatteOrderMetadata] objects arrive from the server. + /// + /// This function applies fields expected to spontaneously change from the + /// server (typically moderation results). It does not meticulously examine + /// every field (like `questions`), which are expected to be set in advance + /// by the server and not randomly updated thereafter. + Future _onNewMetadataFromServer(LatteOrderMetadata? metadata) async { + _logger.finer('New LatteOrderMetadata from server: $metadata'); + if (metadata != null) { + if (metadata.imageBatchId != state.metadata?.imageBatchId) { + _watchImageBatch(metadata.imageBatchId!); + } + if (state.metadata != null) { + if (state.metadata!.isHappyPlaceApproved != true && + metadata.isHappyPlaceApproved == true) { + _logger.fine('Happy place APPROVED by moderation'); + } else if (state.metadata!.isHappyPlaceApproved != false && + metadata.isHappyPlaceApproved == false) { + _logger.fine('Happy place REJECTED by moderation'); + add( + HappyPlaceRejected( + metadata.happyPlaceModerationReason ?? + 'This value did not pass moderation.', + ), + ); + } else if (metadata.imageUrl != null && + state.metadata!.imageUrl != metadata.imageUrl) { + _logger.fine('Image URL has been accepted for Order ${metadata.id}'); + add(const GoToStep(.submitOrder)); + } else if (state.metadata!.status != .submitted && + metadata.status == .submitted) { + _logger.fine('Order ${metadata.id} submitted'); + add(const GoToStep(.confirmation)); + } + } + } + add(ServerMetadataUpdate(metadata)); + } + + void _watchImageBatch(String imageBatchId) { + _logger.fine('_watchImageBatch() called with imageBatchId=$imageBatchId'); + if (imageBatchId == state.imagesBatch?.id) { + _logger.fine('Already watching LatteImageBatch $imageBatchId'); + return; + } + _imageBatchSubscription?.cancel().ignore(); + _logger.fine('Watching ImageBatch Id: $imageBatchId'); + final imagesStream = _imagesRepository.watch(imageBatchId); + _imageBatchSubscription = imagesStream.listen((batch) { + add(UpdateLatteImages(batch)); + }); + + // Finally add the event for broader reactions to this change. + add(NewImageLatteBatch(imageBatchId)); + } + + void _onServerOrderUpdate(ServerOrderUpdate event, _Emit emit) { + _logger.finer( + '_onServerOrderUpdate() called with order.id=${event.order?.id}', + ); + if (event.order != null) { + emit(state.copyWith(order: event.order)); + } else { + add(const StartOver()); + } + } + + void _onServerMetadataUpdate(ServerMetadataUpdate event, _Emit emit) { + _logger.finer('_onServerMetadataUpdate() called'); + emit(state.copyWith(metadata: event.metadata)); + } + + void _onUpdateLatteImages(UpdateLatteImages event, _Emit emit) { + _logger.finer( + '_onUpdateLatteImages() called with batch.id=${event.batch?.id}', + ); + if (state.awaitingQuestions) { + final questionsUsedToBeMissing = + state.imagesBatch?[state.selectedImageIndex!]!.questions == null; + final questionsAreNowAvailable = + event.batch?[state.selectedImageIndex!]!.questions != null; + + if (questionsUsedToBeMissing && questionsAreNowAvailable) { + _logger.fine( + 'Questions are now available for Image ${state.selectedImageIndex}', + ); + _loadQuestions(event.batch); + } + } + emit(state.copyWith(imagesBatch: event.batch)); + } + + void _onNewImageLatteBatch(NewImageLatteBatch event, _Emit emit) { + _logger.finer( + '_onNewImageLatteBatch() called with id: ${event.latteImageBatchId}', + ); + if (state.currentStep == .tweakImage) { + _logger.fine( + 'Navigating to .chooseATweakedImage after answering questions', + ); + emit(state.copyWith(isSubmitting: false, selectedImageIndex: null)); + add(const GoToStep(.chooseATweakedImage)); + } else if (state.currentStep == .chooseATweakedImage) { + emit(state.copyWith(selectedImageIndex: null)); + } + } + + void _onUpdateQuestions(UpdateQuestions event, _Emit emit) { + _logger.info( + 'Loaded questions for index ${state.selectedImageIndex} :: ' + '${event.questions.length} questions', + ); + emit( + state.copyWith( + questions: event.questions, + awaitingQuestions: event.questions.isEmpty, + ), + ); + } + + void _onAnswerQuestion(AnswerQuestion event, _Emit emit) { + _logger.fine( + '_onAnswerQuestion() called for question ${event.question.id} ' + 'with answer: ${event.answer}', + ); + for (final (index, question) in state.questions.indexed) { + if (question.id == event.question.id) { + final questions = List.from( + state.questions, + ); + questions[index] = questions[index].copyWithAnswer(event.answer); + emit(state.copyWith(questions: questions)); + return; + } + } + _logger.warning('Question not found: ${event.question.id}'); + } + + Future _onGenerateRevisedImages( + GenerateRevisedImages event, + _Emit emit, + ) async { + _logger.finer('_onGenerateRevisedImages() called'); + emit( + state.copyWith( + isSubmitting: true, + imageGenerationStartTime: DateTime.now(), + ), + ); + + final answers = state.questions.fold( + {}, + (acc, question) { + acc[question.id] = question.answer; + return acc; + }, + ); + final imageIndex = switch (state.selectedImageIndex!) { + 0 => 'image0', + 1 => 'image1', + 2 => 'image2', + 3 => 'image3', + _ => throw Exception( + 'Unexpected image index: ${state.selectedImageIndex}', + ), + }; + _logger.info( + 'Generating revised images for ${state.imagesBatch!.id}.$imageIndex with ' + 'answers: $answers', + ); + _orderRepository + .generateRevisedImages( + imageBatchId: state.imagesBatch!.id, + imageIndex: imageIndex, + answers: answers, + ) + .then((_) {}) + .catchError((Object error) { + _logger.severe('Failed to generate revised images: $error'); + emit(state.copyWith(isSubmitting: false)); + if (state.currentStep != .tweakImage) { + // If the function fails, return to this step should we have + // navigated forward. + add(const GoToStep(.tweakImage)); + } + }) + .ignore(); + + emit(state.copyWith(imagesBatch: null)); + // Optimistically navigate forward. + add(const GoToStep(.chooseATweakedImage)); + } + + Future _onRejectImageBatch(RejectImageBatch event, _Emit emit) async { + _logger.finer('_onRejectImageBatch() called'); + assert( + state.imagesBatch?.parent != null, + 'Should not be reverting images without an ImageBatch with a parent', + ); + + emit(state.copyWith(isReverting: true)); + await _orderRepository.rejectImageBatch(state.imagesBatch!.id); + _watchImageBatch(state.imagesBatch!.parent!.id); + add(const GoToStep(.chooseAnImage)); + } + + void _onAcceptImage(AcceptImage event, _Emit emit) { + _logger.finer('_onAcceptImage() called'); + assert( + state.selectedImageIndex != null, + 'Should not be accepting images without a selected image', + ); + + // Check whether we're passing through again with an already-accepted + // image. + if (state.metadata!.imageUrl != null && + state.metadata!.imageUrl == + state.imagesBatch![state.selectedImageIndex!]?.imageUrl) { + return add(const GoToStep(.submitOrder)); + } + + emit(state.copyWith(isSubmitting: true)); + _orderRepository + .acceptImage( + imageBatchId: state.imagesBatch!.id, + imageIndex: switch (state.selectedImageIndex!) { + 0 => 'image0', + 1 => 'image1', + 2 => 'image2', + 3 => 'image3', + _ => throw Exception( + 'Unexpected image index: ${state.selectedImageIndex}', + ), + }, + ) + .then((_) { + _logger.fine('Image accepted and order submitted for moderation'); + }) + .catchError((Object error) { + _logger.severe('Failed to accept image and submit order: $error'); + emit(state.copyWith(isSubmitting: false)); + }) + .ignore(); + } + + Future _onSubmitOrder(SubmitOrder event, _Emit emit) async { + _logger.finer('_onSubmitOrder() called'); + assert( + state.metadata?.imageUrl != null, + 'Should not be submitting order without an image', + ); + emit(state.copyWith(isSubmitting: true)); + await _orderRepository + .submitOrder(state.metadata!.id!) + .then((_) { + _logger.fine('Order ${state.metadata!.id} submitted'); + }) + .catchError((Object error) { + _logger.severe('Failed to submit order: $error'); + emit(state.copyWith(isSubmitting: false)); + }); + } + + void _onNewPage(OnNewPage event, _Emit emit) { + _logger.finer('_onNewPage() called with .${event.newStep.name}'); + switch (event.newStep) { + case KioskWizardStep.intro: + if (state.shouldClearData) { + add(const _EjectData()); + } + case KioskWizardStep.name || + KioskWizardStep.milkAndSweetener || + KioskWizardStep.happyPlace || + KioskWizardStep.chooseAnImage || + KioskWizardStep.tweakImage || + KioskWizardStep.chooseATweakedImage || + KioskWizardStep.submitOrder || + KioskWizardStep.confirmation: + // TODO(craiglabenz): Uncomment this after testing milk resetting bug + // (https://github.com/GoogleCloudDemos/gcdemos-26-int-dd-latteart/issues/198) + // if (state.shouldClearData) { + // emit(state.copyWith(shouldClearData: false)); + // } + break; + } + } + + /// [emit] is nullable because when this is called during absolute + /// initialization, an emitter function is both unavailable and we do not need + /// to worry about clearing the `isSubmitting` field. + void _loadForStep(KioskWizardStep step, _Emit? emit) { + if (state.isSubmitting || state.isReverting) { + // The one thing we know is that, upon arriving at a new step, we are no + // longer submitting whatever data concluded the previous step. + emit?.call(state.copyWith(isSubmitting: false, isReverting: false)); + } + switch (step) { + case KioskWizardStep.intro: + break; + case KioskWizardStep.name: + break; + case KioskWizardStep.milkAndSweetener: + break; + case KioskWizardStep.happyPlace: + break; + case KioskWizardStep.chooseAnImage: + break; + case KioskWizardStep.tweakImage: + _loadQuestions(); + case KioskWizardStep.chooseATweakedImage: + break; + case KioskWizardStep.submitOrder: + break; + case KioskWizardStep.confirmation: + break; + } + } + + /// Sends questions from the selected image to the UI. + /// + /// Optionally pass a specific batch to use if the server has just sent an + /// updated version of the image batch which we know freshly contains these + /// very questions that we care about. Otherwise, read from the existing + /// state variable. + void _loadQuestions([LatteImageBatch? batch]) { + final batchToUse = batch ?? state.imagesBatch; + if (batchToUse != null && state.selectedImageIndex != null) { + final images = batchToUse.images.toList(); + final selectedImage = images[state.selectedImageIndex!]; + if (selectedImage?.questions != null) { + add(UpdateQuestions(selectedImage!.questions!)); + return; + } + } + add(const UpdateQuestions([])); + } + + void _onSelectImage(SelectImage event, _Emit emit) { + _logger.fine('_onSelectImage() called with index: ${event.index}'); + emit(state.copyWith(selectedImageIndex: event.index)); + } + + @override + Future close() { + _milkOptionsSubscription?.cancel().ignore(); + _sweetenerOptionsSubscription?.cancel().ignore(); + _orderSubscription?.cancel().ignore(); + _metadataSubscription?.cancel().ignore(); + _imageBatchSubscription?.cancel().ignore(); + return super.close(); + } +} + +/// Actions that can be taken on the KioskHome page. +@Freezed() +sealed class KioskHomeEvent with _$KioskHomeEvent { + /// A new page is being shown. Called once at the beginning of a new page's + /// animation into the screen. + const factory KioskHomeEvent.onNewPage(KioskWizardStep newStep) = OnNewPage; + + /// Sends the user back to the intro screen and sets a flag which will + /// cause any existing orders to be ejected from memory when the user arrives + /// there. + const factory KioskHomeEvent.startOver() = StartOver; + + /// An internal event thrown after [StartOver]. + const factory KioskHomeEvent.ejectData() = _EjectData; + + /// Possibly triggered on start-up if an existing, partially finished + /// [LatteOrder] is found locally. This allows a restarted browser to not lose + /// progress. + const factory KioskHomeEvent.loadPreExistingOrders( + List orders, + ) = LoadPreExistingOrders; + + /// Overwrites all state for a given order; used to resume progress after + /// [LoadPreExistingOrders] has succeeded. + const factory KioskHomeEvent.applyPreExistingOrder( + LatteOrder order, + LatteOrderMetadata metadata, + ) = ApplyPreExistingOrder; + + /// Refreshed milk options have arrived from the server. + const factory KioskHomeEvent.updateMilkOptions( + List? milkOptions, + ) = UpdateMilkOptions; + + /// Refreshed sweetener options have arrived from the server. + const factory KioskHomeEvent.updateSweetenerOptions( + List? sweetenerOptions, + ) = UpdateSweetenerOptions; + + /// Locally set the user's preferred milk. + const factory KioskHomeEvent.selectMilk(String milk) = SelectMilk; + + /// Locally set the user's preferred sweetener. + const factory KioskHomeEvent.selectSweetener(String sweetener) = + SelectSweetener; + + /// Submit the user's milk and sweetener preferences. + const factory KioskHomeEvent.submitMilkAndSweetener() = + SubmitMilkAndSweetener; + + /// Submit the user's happy place. + const factory KioskHomeEvent.submitHappyPlace(String happyPlace) = + SubmitHappyPlace; + + /// Gemini has rejected the user's happy place. + const factory KioskHomeEvent.happyPlaceRejected(String reason) = + HappyPlaceRejected; + + /// The user has been shown the happy place moderation reason, meaning we can + /// clear it from the state so it isn't shown on every subsequent change. + const factory KioskHomeEvent.happyPlaceModerationReasonShown() = + HappyPlaceModerationReasonShown; + + /// The [LatteOrder] has been updated by the server. + const factory KioskHomeEvent.serverOrderUpdate(LatteOrder? order) = + ServerOrderUpdate; + + /// The [LatteOrderMetadata] has been updated by the server. + const factory KioskHomeEvent.serverMetadataUpdate( + LatteOrderMetadata? metadata, + ) = ServerMetadataUpdate; + + /// Go back to the previous step in the wizard. + const factory KioskHomeEvent.goBack() = GoBackKioskWizard; + + /// Go to a specific step in the wizard. + const factory KioskHomeEvent.goToStep(KioskWizardStep step) = GoToStep; + + /// Submit the user's name. + const factory KioskHomeEvent.submitUserName(String name) = SubmitUserName; + + /// The user has selected an image from within the batch. Note that + /// this only amounts to tapping on the image. It does not mean the image + /// has been fully accepted for a submitted order. + const factory KioskHomeEvent.selectImage(int index) = SelectImage; + + /// The server has updated the [LatteImageBatch] that is being watched. Note + /// that this is different from the server changing *which* [LatteImageBatch] + /// is being watched. For that event, see [NewImageLatteBatch]. + const factory KioskHomeEvent.updateLatteImages(LatteImageBatch? batch) = + UpdateLatteImages; + + /// An update to the watched [LatteOrderMetadata] object has returned a new + /// [LatteImageBatch] id. This is different from changes to the currently + /// watched [LatteImageBatch] id. For that, see [UpdateLatteImages]. + const factory KioskHomeEvent.newImageLatteBatch(String latteImageBatchId) = + NewImageLatteBatch; + + /// Update the list of available questions from a selected image. This fires + /// when the user selects an image to tweak. + const factory KioskHomeEvent.updateQuestions(List questions) = + UpdateQuestions; + + /// Saves a user's answer to a question. + const factory KioskHomeEvent.answerQuestion( + Question question, + Object? answer, + ) = AnswerQuestion; + + /// Sends the user's answers to the questions to the backend to generate + /// new images. If this call to the server is successful, a + /// [NewImageLatteBatch] event is expected in short order. + const factory KioskHomeEvent.generateRevisedImages() = GenerateRevisedImages; + + /// The user has rejected the revised images. + const factory KioskHomeEvent.rejectImageBatch() = RejectImageBatch; + + /// The user has accepted their selected image. + const factory KioskHomeEvent.acceptImage() = AcceptImage; + + /// The user has submitted their finalized order. + const factory KioskHomeEvent.submitOrder() = SubmitOrder; +} + +/// {@template KioskHomeState} +/// Complete representation of the KioskHome page's state. +/// {@endtemplate +@Freezed() +abstract class KioskHomeState with _$KioskHomeState { + /// {@macro KioskHomeState} + const factory KioskHomeState({ + required KioskWizardStep currentStep, + LatteOrder? order, + LatteOrderMetadata? metadata, + ModerationEvent? happyPlaceModerationEvent, + @Default(false) bool isSubmitting, + @Default(false) bool isReverting, + + /// When the user submits a happy place, or a tweak, + /// the clock starts ticking on image generation. + /// We want to track this time. If for nothing else, it helps us show + /// a progress bar that doesn't depend on widget states being mounted + /// throughout the image generation without gaps. + DateTime? imageGenerationStartTime, + + /// When an image's questions are requested before they exist, we store the + /// need here so that we add them to the state once they arrive. + @Default(false) bool awaitingQuestions, + + /// The current batch of 4 images to choose from. This is deduced by the + /// [LatteOrderMetadata]'s `latteBatchId` field. + LatteImageBatch? imagesBatch, + + /// Copy of the active [LatteImage]'s questions, only set once the user + /// chooses an image and clicks the "tweak image" button. This copy is used + /// instead of the questions in the [LatteImage] to isolate the user's + /// answers from changes to the [LatteImageBatch]. The critical detail is + /// that answers are not persisted to Firebase, which means anything that + /// overwrites the [LatteImageBatch] would clobber the user's answers. + @Default([]) List questions, + + /// Values loaded straight from Firebase. + @LatteOptionConverter() List? milkOptions, + + /// Values loaded straight from Firebase. + @LatteOptionConverter() List? sweetenerOptions, + + /// True if the user is starting over and we want to eject data upon + /// returning to the .intro screen. + @Default(false) bool shouldClearData, + + int? selectedImageIndex, + }) = _KioskHomeState; + const KioskHomeState._(); + + /// Starter state fed to the [KioskHomeBloc]. + factory KioskHomeState.initial() => KioskHomeState( + currentStep: KioskWizardStep.values.first, + ); +} + +/// The steps in the kiosk wizard. +enum KioskWizardStep { + /// Establishing expectations / describing the process. + intro, + + /// Collecting the user's name. + name, + + /// Collecting the user's milk / sweetener preferences. + milkAndSweetener, + + /// Collecting the user's happy place. + happyPlace, + + /// The user's first time choosing 1 of 4 images + chooseAnImage, + + /// Answer Gemini's questions to configure the image + tweakImage, + + /// Similar to [chooseAnImage], but with extra awareness that this is a forked + /// image batch which the user could outright reject + chooseATweakedImage, + + /// Order review screen where the user submits their order. + submitOrder, + + /// Post-submission confirmation screen to conclude the user's journey. + confirmation, + ; + + /// The current step's place in line. + int get stepIndex => values.indexOf(this); + + /// Returns the given step's next step. + KioskWizardStep? get nextStep { + final index = stepIndex; + if (index >= totalSteps) { + return null; + } + return fromIndex(index + 1); + } + + /// Returns the given step's previous step. + KioskWizardStep? get previousStep { + final index = stepIndex; + if (index <= 0) { + return null; + } + return fromIndex(index - 1); + } + + /// The step at the given index. + static KioskWizardStep fromIndex(int index) => values[index]; + + /// True if this is the first step. + bool get isFirstStep => this == values.first; + + /// True if this is the last step. + bool get isLastStep => this == values.last; + + /// The total number of steps in the wizard. + int get totalSteps => values.length; + + /// The index to use for the progress indicator. + int get progressIndicatorIndex => switch (this) { + KioskWizardStep.intro => 0, + KioskWizardStep.name => 0, + KioskWizardStep.milkAndSweetener => 1, + KioskWizardStep.happyPlace => 2, + KioskWizardStep.chooseAnImage => 2, + KioskWizardStep.tweakImage => 2, + KioskWizardStep.chooseATweakedImage => 2, + KioskWizardStep.submitOrder => 3, + KioskWizardStep.confirmation => 3, + }; +} + +/// Bundles a server moderation reason with a Uuidv4 which is used to help +/// the UI display only 1 toast per moderation event. +@immutable +class ModerationEvent { + /// Instantiates a [ModerationEvent]. + ModerationEvent(this.reason) : id = const Uuid().v4(); + + /// The moderation reason. + final String reason; + + /// Unique Id. + final String id; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is ModerationEvent && + runtimeType == other.runtimeType && + reason == other.reason && + id == other.id; + + @override + int get hashCode => reason.hashCode ^ id.hashCode; +} diff --git a/genlatte/genlatte-ui/lib/src/screens/kiosk/home/kiosk_home_bloc.freezed.dart b/genlatte/genlatte-ui/lib/src/screens/kiosk/home/kiosk_home_bloc.freezed.dart new file mode 100644 index 0000000..bee12fb --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/screens/kiosk/home/kiosk_home_bloc.freezed.dart @@ -0,0 +1,2334 @@ +// Copyright 2014 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. + +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'kiosk_home_bloc.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; +/// @nodoc +mixin _$KioskHomeEvent { + + + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is KioskHomeEvent); +} + + +@override +int get hashCode => runtimeType.hashCode; + +@override +String toString() { + return 'KioskHomeEvent()'; +} + + +} + +/// @nodoc +class $KioskHomeEventCopyWith<$Res> { +$KioskHomeEventCopyWith(KioskHomeEvent _, $Res Function(KioskHomeEvent) __); +} + + +/// Adds pattern-matching-related methods to [KioskHomeEvent]. +extension KioskHomeEventPatterns on KioskHomeEvent { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap({TResult Function( OnNewPage value)? onNewPage,TResult Function( StartOver value)? startOver,TResult Function( _EjectData value)? ejectData,TResult Function( LoadPreExistingOrders value)? loadPreExistingOrders,TResult Function( ApplyPreExistingOrder value)? applyPreExistingOrder,TResult Function( UpdateMilkOptions value)? updateMilkOptions,TResult Function( UpdateSweetenerOptions value)? updateSweetenerOptions,TResult Function( SelectMilk value)? selectMilk,TResult Function( SelectSweetener value)? selectSweetener,TResult Function( SubmitMilkAndSweetener value)? submitMilkAndSweetener,TResult Function( SubmitHappyPlace value)? submitHappyPlace,TResult Function( HappyPlaceRejected value)? happyPlaceRejected,TResult Function( HappyPlaceModerationReasonShown value)? happyPlaceModerationReasonShown,TResult Function( ServerOrderUpdate value)? serverOrderUpdate,TResult Function( ServerMetadataUpdate value)? serverMetadataUpdate,TResult Function( GoBackKioskWizard value)? goBack,TResult Function( GoToStep value)? goToStep,TResult Function( SubmitUserName value)? submitUserName,TResult Function( SelectImage value)? selectImage,TResult Function( UpdateLatteImages value)? updateLatteImages,TResult Function( NewImageLatteBatch value)? newImageLatteBatch,TResult Function( UpdateQuestions value)? updateQuestions,TResult Function( AnswerQuestion value)? answerQuestion,TResult Function( GenerateRevisedImages value)? generateRevisedImages,TResult Function( RejectImageBatch value)? rejectImageBatch,TResult Function( AcceptImage value)? acceptImage,TResult Function( SubmitOrder value)? submitOrder,required TResult orElse(),}){ +final _that = this; +switch (_that) { +case OnNewPage() when onNewPage != null: +return onNewPage(_that);case StartOver() when startOver != null: +return startOver(_that);case _EjectData() when ejectData != null: +return ejectData(_that);case LoadPreExistingOrders() when loadPreExistingOrders != null: +return loadPreExistingOrders(_that);case ApplyPreExistingOrder() when applyPreExistingOrder != null: +return applyPreExistingOrder(_that);case UpdateMilkOptions() when updateMilkOptions != null: +return updateMilkOptions(_that);case UpdateSweetenerOptions() when updateSweetenerOptions != null: +return updateSweetenerOptions(_that);case SelectMilk() when selectMilk != null: +return selectMilk(_that);case SelectSweetener() when selectSweetener != null: +return selectSweetener(_that);case SubmitMilkAndSweetener() when submitMilkAndSweetener != null: +return submitMilkAndSweetener(_that);case SubmitHappyPlace() when submitHappyPlace != null: +return submitHappyPlace(_that);case HappyPlaceRejected() when happyPlaceRejected != null: +return happyPlaceRejected(_that);case HappyPlaceModerationReasonShown() when happyPlaceModerationReasonShown != null: +return happyPlaceModerationReasonShown(_that);case ServerOrderUpdate() when serverOrderUpdate != null: +return serverOrderUpdate(_that);case ServerMetadataUpdate() when serverMetadataUpdate != null: +return serverMetadataUpdate(_that);case GoBackKioskWizard() when goBack != null: +return goBack(_that);case GoToStep() when goToStep != null: +return goToStep(_that);case SubmitUserName() when submitUserName != null: +return submitUserName(_that);case SelectImage() when selectImage != null: +return selectImage(_that);case UpdateLatteImages() when updateLatteImages != null: +return updateLatteImages(_that);case NewImageLatteBatch() when newImageLatteBatch != null: +return newImageLatteBatch(_that);case UpdateQuestions() when updateQuestions != null: +return updateQuestions(_that);case AnswerQuestion() when answerQuestion != null: +return answerQuestion(_that);case GenerateRevisedImages() when generateRevisedImages != null: +return generateRevisedImages(_that);case RejectImageBatch() when rejectImageBatch != null: +return rejectImageBatch(_that);case AcceptImage() when acceptImage != null: +return acceptImage(_that);case SubmitOrder() when submitOrder != null: +return submitOrder(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map({required TResult Function( OnNewPage value) onNewPage,required TResult Function( StartOver value) startOver,required TResult Function( _EjectData value) ejectData,required TResult Function( LoadPreExistingOrders value) loadPreExistingOrders,required TResult Function( ApplyPreExistingOrder value) applyPreExistingOrder,required TResult Function( UpdateMilkOptions value) updateMilkOptions,required TResult Function( UpdateSweetenerOptions value) updateSweetenerOptions,required TResult Function( SelectMilk value) selectMilk,required TResult Function( SelectSweetener value) selectSweetener,required TResult Function( SubmitMilkAndSweetener value) submitMilkAndSweetener,required TResult Function( SubmitHappyPlace value) submitHappyPlace,required TResult Function( HappyPlaceRejected value) happyPlaceRejected,required TResult Function( HappyPlaceModerationReasonShown value) happyPlaceModerationReasonShown,required TResult Function( ServerOrderUpdate value) serverOrderUpdate,required TResult Function( ServerMetadataUpdate value) serverMetadataUpdate,required TResult Function( GoBackKioskWizard value) goBack,required TResult Function( GoToStep value) goToStep,required TResult Function( SubmitUserName value) submitUserName,required TResult Function( SelectImage value) selectImage,required TResult Function( UpdateLatteImages value) updateLatteImages,required TResult Function( NewImageLatteBatch value) newImageLatteBatch,required TResult Function( UpdateQuestions value) updateQuestions,required TResult Function( AnswerQuestion value) answerQuestion,required TResult Function( GenerateRevisedImages value) generateRevisedImages,required TResult Function( RejectImageBatch value) rejectImageBatch,required TResult Function( AcceptImage value) acceptImage,required TResult Function( SubmitOrder value) submitOrder,}){ +final _that = this; +switch (_that) { +case OnNewPage(): +return onNewPage(_that);case StartOver(): +return startOver(_that);case _EjectData(): +return ejectData(_that);case LoadPreExistingOrders(): +return loadPreExistingOrders(_that);case ApplyPreExistingOrder(): +return applyPreExistingOrder(_that);case UpdateMilkOptions(): +return updateMilkOptions(_that);case UpdateSweetenerOptions(): +return updateSweetenerOptions(_that);case SelectMilk(): +return selectMilk(_that);case SelectSweetener(): +return selectSweetener(_that);case SubmitMilkAndSweetener(): +return submitMilkAndSweetener(_that);case SubmitHappyPlace(): +return submitHappyPlace(_that);case HappyPlaceRejected(): +return happyPlaceRejected(_that);case HappyPlaceModerationReasonShown(): +return happyPlaceModerationReasonShown(_that);case ServerOrderUpdate(): +return serverOrderUpdate(_that);case ServerMetadataUpdate(): +return serverMetadataUpdate(_that);case GoBackKioskWizard(): +return goBack(_that);case GoToStep(): +return goToStep(_that);case SubmitUserName(): +return submitUserName(_that);case SelectImage(): +return selectImage(_that);case UpdateLatteImages(): +return updateLatteImages(_that);case NewImageLatteBatch(): +return newImageLatteBatch(_that);case UpdateQuestions(): +return updateQuestions(_that);case AnswerQuestion(): +return answerQuestion(_that);case GenerateRevisedImages(): +return generateRevisedImages(_that);case RejectImageBatch(): +return rejectImageBatch(_that);case AcceptImage(): +return acceptImage(_that);case SubmitOrder(): +return submitOrder(_that);} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull({TResult? Function( OnNewPage value)? onNewPage,TResult? Function( StartOver value)? startOver,TResult? Function( _EjectData value)? ejectData,TResult? Function( LoadPreExistingOrders value)? loadPreExistingOrders,TResult? Function( ApplyPreExistingOrder value)? applyPreExistingOrder,TResult? Function( UpdateMilkOptions value)? updateMilkOptions,TResult? Function( UpdateSweetenerOptions value)? updateSweetenerOptions,TResult? Function( SelectMilk value)? selectMilk,TResult? Function( SelectSweetener value)? selectSweetener,TResult? Function( SubmitMilkAndSweetener value)? submitMilkAndSweetener,TResult? Function( SubmitHappyPlace value)? submitHappyPlace,TResult? Function( HappyPlaceRejected value)? happyPlaceRejected,TResult? Function( HappyPlaceModerationReasonShown value)? happyPlaceModerationReasonShown,TResult? Function( ServerOrderUpdate value)? serverOrderUpdate,TResult? Function( ServerMetadataUpdate value)? serverMetadataUpdate,TResult? Function( GoBackKioskWizard value)? goBack,TResult? Function( GoToStep value)? goToStep,TResult? Function( SubmitUserName value)? submitUserName,TResult? Function( SelectImage value)? selectImage,TResult? Function( UpdateLatteImages value)? updateLatteImages,TResult? Function( NewImageLatteBatch value)? newImageLatteBatch,TResult? Function( UpdateQuestions value)? updateQuestions,TResult? Function( AnswerQuestion value)? answerQuestion,TResult? Function( GenerateRevisedImages value)? generateRevisedImages,TResult? Function( RejectImageBatch value)? rejectImageBatch,TResult? Function( AcceptImage value)? acceptImage,TResult? Function( SubmitOrder value)? submitOrder,}){ +final _that = this; +switch (_that) { +case OnNewPage() when onNewPage != null: +return onNewPage(_that);case StartOver() when startOver != null: +return startOver(_that);case _EjectData() when ejectData != null: +return ejectData(_that);case LoadPreExistingOrders() when loadPreExistingOrders != null: +return loadPreExistingOrders(_that);case ApplyPreExistingOrder() when applyPreExistingOrder != null: +return applyPreExistingOrder(_that);case UpdateMilkOptions() when updateMilkOptions != null: +return updateMilkOptions(_that);case UpdateSweetenerOptions() when updateSweetenerOptions != null: +return updateSweetenerOptions(_that);case SelectMilk() when selectMilk != null: +return selectMilk(_that);case SelectSweetener() when selectSweetener != null: +return selectSweetener(_that);case SubmitMilkAndSweetener() when submitMilkAndSweetener != null: +return submitMilkAndSweetener(_that);case SubmitHappyPlace() when submitHappyPlace != null: +return submitHappyPlace(_that);case HappyPlaceRejected() when happyPlaceRejected != null: +return happyPlaceRejected(_that);case HappyPlaceModerationReasonShown() when happyPlaceModerationReasonShown != null: +return happyPlaceModerationReasonShown(_that);case ServerOrderUpdate() when serverOrderUpdate != null: +return serverOrderUpdate(_that);case ServerMetadataUpdate() when serverMetadataUpdate != null: +return serverMetadataUpdate(_that);case GoBackKioskWizard() when goBack != null: +return goBack(_that);case GoToStep() when goToStep != null: +return goToStep(_that);case SubmitUserName() when submitUserName != null: +return submitUserName(_that);case SelectImage() when selectImage != null: +return selectImage(_that);case UpdateLatteImages() when updateLatteImages != null: +return updateLatteImages(_that);case NewImageLatteBatch() when newImageLatteBatch != null: +return newImageLatteBatch(_that);case UpdateQuestions() when updateQuestions != null: +return updateQuestions(_that);case AnswerQuestion() when answerQuestion != null: +return answerQuestion(_that);case GenerateRevisedImages() when generateRevisedImages != null: +return generateRevisedImages(_that);case RejectImageBatch() when rejectImageBatch != null: +return rejectImageBatch(_that);case AcceptImage() when acceptImage != null: +return acceptImage(_that);case SubmitOrder() when submitOrder != null: +return submitOrder(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen({TResult Function( KioskWizardStep newStep)? onNewPage,TResult Function()? startOver,TResult Function()? ejectData,TResult Function( List orders)? loadPreExistingOrders,TResult Function( LatteOrder order, LatteOrderMetadata metadata)? applyPreExistingOrder,TResult Function( List? milkOptions)? updateMilkOptions,TResult Function( List? sweetenerOptions)? updateSweetenerOptions,TResult Function( String milk)? selectMilk,TResult Function( String sweetener)? selectSweetener,TResult Function()? submitMilkAndSweetener,TResult Function( String happyPlace)? submitHappyPlace,TResult Function( String reason)? happyPlaceRejected,TResult Function()? happyPlaceModerationReasonShown,TResult Function( LatteOrder? order)? serverOrderUpdate,TResult Function( LatteOrderMetadata? metadata)? serverMetadataUpdate,TResult Function()? goBack,TResult Function( KioskWizardStep step)? goToStep,TResult Function( String name)? submitUserName,TResult Function( int index)? selectImage,TResult Function( LatteImageBatch? batch)? updateLatteImages,TResult Function( String latteImageBatchId)? newImageLatteBatch,TResult Function( List questions)? updateQuestions,TResult Function( Question question, Object? answer)? answerQuestion,TResult Function()? generateRevisedImages,TResult Function()? rejectImageBatch,TResult Function()? acceptImage,TResult Function()? submitOrder,required TResult orElse(),}) {final _that = this; +switch (_that) { +case OnNewPage() when onNewPage != null: +return onNewPage(_that.newStep);case StartOver() when startOver != null: +return startOver();case _EjectData() when ejectData != null: +return ejectData();case LoadPreExistingOrders() when loadPreExistingOrders != null: +return loadPreExistingOrders(_that.orders);case ApplyPreExistingOrder() when applyPreExistingOrder != null: +return applyPreExistingOrder(_that.order,_that.metadata);case UpdateMilkOptions() when updateMilkOptions != null: +return updateMilkOptions(_that.milkOptions);case UpdateSweetenerOptions() when updateSweetenerOptions != null: +return updateSweetenerOptions(_that.sweetenerOptions);case SelectMilk() when selectMilk != null: +return selectMilk(_that.milk);case SelectSweetener() when selectSweetener != null: +return selectSweetener(_that.sweetener);case SubmitMilkAndSweetener() when submitMilkAndSweetener != null: +return submitMilkAndSweetener();case SubmitHappyPlace() when submitHappyPlace != null: +return submitHappyPlace(_that.happyPlace);case HappyPlaceRejected() when happyPlaceRejected != null: +return happyPlaceRejected(_that.reason);case HappyPlaceModerationReasonShown() when happyPlaceModerationReasonShown != null: +return happyPlaceModerationReasonShown();case ServerOrderUpdate() when serverOrderUpdate != null: +return serverOrderUpdate(_that.order);case ServerMetadataUpdate() when serverMetadataUpdate != null: +return serverMetadataUpdate(_that.metadata);case GoBackKioskWizard() when goBack != null: +return goBack();case GoToStep() when goToStep != null: +return goToStep(_that.step);case SubmitUserName() when submitUserName != null: +return submitUserName(_that.name);case SelectImage() when selectImage != null: +return selectImage(_that.index);case UpdateLatteImages() when updateLatteImages != null: +return updateLatteImages(_that.batch);case NewImageLatteBatch() when newImageLatteBatch != null: +return newImageLatteBatch(_that.latteImageBatchId);case UpdateQuestions() when updateQuestions != null: +return updateQuestions(_that.questions);case AnswerQuestion() when answerQuestion != null: +return answerQuestion(_that.question,_that.answer);case GenerateRevisedImages() when generateRevisedImages != null: +return generateRevisedImages();case RejectImageBatch() when rejectImageBatch != null: +return rejectImageBatch();case AcceptImage() when acceptImage != null: +return acceptImage();case SubmitOrder() when submitOrder != null: +return submitOrder();case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when({required TResult Function( KioskWizardStep newStep) onNewPage,required TResult Function() startOver,required TResult Function() ejectData,required TResult Function( List orders) loadPreExistingOrders,required TResult Function( LatteOrder order, LatteOrderMetadata metadata) applyPreExistingOrder,required TResult Function( List? milkOptions) updateMilkOptions,required TResult Function( List? sweetenerOptions) updateSweetenerOptions,required TResult Function( String milk) selectMilk,required TResult Function( String sweetener) selectSweetener,required TResult Function() submitMilkAndSweetener,required TResult Function( String happyPlace) submitHappyPlace,required TResult Function( String reason) happyPlaceRejected,required TResult Function() happyPlaceModerationReasonShown,required TResult Function( LatteOrder? order) serverOrderUpdate,required TResult Function( LatteOrderMetadata? metadata) serverMetadataUpdate,required TResult Function() goBack,required TResult Function( KioskWizardStep step) goToStep,required TResult Function( String name) submitUserName,required TResult Function( int index) selectImage,required TResult Function( LatteImageBatch? batch) updateLatteImages,required TResult Function( String latteImageBatchId) newImageLatteBatch,required TResult Function( List questions) updateQuestions,required TResult Function( Question question, Object? answer) answerQuestion,required TResult Function() generateRevisedImages,required TResult Function() rejectImageBatch,required TResult Function() acceptImage,required TResult Function() submitOrder,}) {final _that = this; +switch (_that) { +case OnNewPage(): +return onNewPage(_that.newStep);case StartOver(): +return startOver();case _EjectData(): +return ejectData();case LoadPreExistingOrders(): +return loadPreExistingOrders(_that.orders);case ApplyPreExistingOrder(): +return applyPreExistingOrder(_that.order,_that.metadata);case UpdateMilkOptions(): +return updateMilkOptions(_that.milkOptions);case UpdateSweetenerOptions(): +return updateSweetenerOptions(_that.sweetenerOptions);case SelectMilk(): +return selectMilk(_that.milk);case SelectSweetener(): +return selectSweetener(_that.sweetener);case SubmitMilkAndSweetener(): +return submitMilkAndSweetener();case SubmitHappyPlace(): +return submitHappyPlace(_that.happyPlace);case HappyPlaceRejected(): +return happyPlaceRejected(_that.reason);case HappyPlaceModerationReasonShown(): +return happyPlaceModerationReasonShown();case ServerOrderUpdate(): +return serverOrderUpdate(_that.order);case ServerMetadataUpdate(): +return serverMetadataUpdate(_that.metadata);case GoBackKioskWizard(): +return goBack();case GoToStep(): +return goToStep(_that.step);case SubmitUserName(): +return submitUserName(_that.name);case SelectImage(): +return selectImage(_that.index);case UpdateLatteImages(): +return updateLatteImages(_that.batch);case NewImageLatteBatch(): +return newImageLatteBatch(_that.latteImageBatchId);case UpdateQuestions(): +return updateQuestions(_that.questions);case AnswerQuestion(): +return answerQuestion(_that.question,_that.answer);case GenerateRevisedImages(): +return generateRevisedImages();case RejectImageBatch(): +return rejectImageBatch();case AcceptImage(): +return acceptImage();case SubmitOrder(): +return submitOrder();} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull({TResult? Function( KioskWizardStep newStep)? onNewPage,TResult? Function()? startOver,TResult? Function()? ejectData,TResult? Function( List orders)? loadPreExistingOrders,TResult? Function( LatteOrder order, LatteOrderMetadata metadata)? applyPreExistingOrder,TResult? Function( List? milkOptions)? updateMilkOptions,TResult? Function( List? sweetenerOptions)? updateSweetenerOptions,TResult? Function( String milk)? selectMilk,TResult? Function( String sweetener)? selectSweetener,TResult? Function()? submitMilkAndSweetener,TResult? Function( String happyPlace)? submitHappyPlace,TResult? Function( String reason)? happyPlaceRejected,TResult? Function()? happyPlaceModerationReasonShown,TResult? Function( LatteOrder? order)? serverOrderUpdate,TResult? Function( LatteOrderMetadata? metadata)? serverMetadataUpdate,TResult? Function()? goBack,TResult? Function( KioskWizardStep step)? goToStep,TResult? Function( String name)? submitUserName,TResult? Function( int index)? selectImage,TResult? Function( LatteImageBatch? batch)? updateLatteImages,TResult? Function( String latteImageBatchId)? newImageLatteBatch,TResult? Function( List questions)? updateQuestions,TResult? Function( Question question, Object? answer)? answerQuestion,TResult? Function()? generateRevisedImages,TResult? Function()? rejectImageBatch,TResult? Function()? acceptImage,TResult? Function()? submitOrder,}) {final _that = this; +switch (_that) { +case OnNewPage() when onNewPage != null: +return onNewPage(_that.newStep);case StartOver() when startOver != null: +return startOver();case _EjectData() when ejectData != null: +return ejectData();case LoadPreExistingOrders() when loadPreExistingOrders != null: +return loadPreExistingOrders(_that.orders);case ApplyPreExistingOrder() when applyPreExistingOrder != null: +return applyPreExistingOrder(_that.order,_that.metadata);case UpdateMilkOptions() when updateMilkOptions != null: +return updateMilkOptions(_that.milkOptions);case UpdateSweetenerOptions() when updateSweetenerOptions != null: +return updateSweetenerOptions(_that.sweetenerOptions);case SelectMilk() when selectMilk != null: +return selectMilk(_that.milk);case SelectSweetener() when selectSweetener != null: +return selectSweetener(_that.sweetener);case SubmitMilkAndSweetener() when submitMilkAndSweetener != null: +return submitMilkAndSweetener();case SubmitHappyPlace() when submitHappyPlace != null: +return submitHappyPlace(_that.happyPlace);case HappyPlaceRejected() when happyPlaceRejected != null: +return happyPlaceRejected(_that.reason);case HappyPlaceModerationReasonShown() when happyPlaceModerationReasonShown != null: +return happyPlaceModerationReasonShown();case ServerOrderUpdate() when serverOrderUpdate != null: +return serverOrderUpdate(_that.order);case ServerMetadataUpdate() when serverMetadataUpdate != null: +return serverMetadataUpdate(_that.metadata);case GoBackKioskWizard() when goBack != null: +return goBack();case GoToStep() when goToStep != null: +return goToStep(_that.step);case SubmitUserName() when submitUserName != null: +return submitUserName(_that.name);case SelectImage() when selectImage != null: +return selectImage(_that.index);case UpdateLatteImages() when updateLatteImages != null: +return updateLatteImages(_that.batch);case NewImageLatteBatch() when newImageLatteBatch != null: +return newImageLatteBatch(_that.latteImageBatchId);case UpdateQuestions() when updateQuestions != null: +return updateQuestions(_that.questions);case AnswerQuestion() when answerQuestion != null: +return answerQuestion(_that.question,_that.answer);case GenerateRevisedImages() when generateRevisedImages != null: +return generateRevisedImages();case RejectImageBatch() when rejectImageBatch != null: +return rejectImageBatch();case AcceptImage() when acceptImage != null: +return acceptImage();case SubmitOrder() when submitOrder != null: +return submitOrder();case _: + return null; + +} +} + +} + +/// @nodoc + + +class OnNewPage implements KioskHomeEvent { + const OnNewPage(this.newStep); + + + final KioskWizardStep newStep; + +/// Create a copy of KioskHomeEvent +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$OnNewPageCopyWith get copyWith => _$OnNewPageCopyWithImpl(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is OnNewPage&&(identical(other.newStep, newStep) || other.newStep == newStep)); +} + + +@override +int get hashCode => Object.hash(runtimeType,newStep); + +@override +String toString() { + return 'KioskHomeEvent.onNewPage(newStep: $newStep)'; +} + + +} + +/// @nodoc +abstract mixin class $OnNewPageCopyWith<$Res> implements $KioskHomeEventCopyWith<$Res> { + factory $OnNewPageCopyWith(OnNewPage value, $Res Function(OnNewPage) _then) = _$OnNewPageCopyWithImpl; +@useResult +$Res call({ + KioskWizardStep newStep +}); + + + + +} +/// @nodoc +class _$OnNewPageCopyWithImpl<$Res> + implements $OnNewPageCopyWith<$Res> { + _$OnNewPageCopyWithImpl(this._self, this._then); + + final OnNewPage _self; + final $Res Function(OnNewPage) _then; + +/// Create a copy of KioskHomeEvent +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') $Res call({Object? newStep = null,}) { + return _then(OnNewPage( +null == newStep ? _self.newStep : newStep // ignore: cast_nullable_to_non_nullable +as KioskWizardStep, + )); +} + + +} + +/// @nodoc + + +class StartOver implements KioskHomeEvent { + const StartOver(); + + + + + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is StartOver); +} + + +@override +int get hashCode => runtimeType.hashCode; + +@override +String toString() { + return 'KioskHomeEvent.startOver()'; +} + + +} + + + + +/// @nodoc + + +class _EjectData implements KioskHomeEvent { + const _EjectData(); + + + + + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _EjectData); +} + + +@override +int get hashCode => runtimeType.hashCode; + +@override +String toString() { + return 'KioskHomeEvent.ejectData()'; +} + + +} + + + + +/// @nodoc + + +class LoadPreExistingOrders implements KioskHomeEvent { + const LoadPreExistingOrders(final List orders): _orders = orders; + + + final List _orders; + List get orders { + if (_orders is EqualUnmodifiableListView) return _orders; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_orders); +} + + +/// Create a copy of KioskHomeEvent +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$LoadPreExistingOrdersCopyWith get copyWith => _$LoadPreExistingOrdersCopyWithImpl(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is LoadPreExistingOrders&&const DeepCollectionEquality().equals(other._orders, _orders)); +} + + +@override +int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(_orders)); + +@override +String toString() { + return 'KioskHomeEvent.loadPreExistingOrders(orders: $orders)'; +} + + +} + +/// @nodoc +abstract mixin class $LoadPreExistingOrdersCopyWith<$Res> implements $KioskHomeEventCopyWith<$Res> { + factory $LoadPreExistingOrdersCopyWith(LoadPreExistingOrders value, $Res Function(LoadPreExistingOrders) _then) = _$LoadPreExistingOrdersCopyWithImpl; +@useResult +$Res call({ + List orders +}); + + + + +} +/// @nodoc +class _$LoadPreExistingOrdersCopyWithImpl<$Res> + implements $LoadPreExistingOrdersCopyWith<$Res> { + _$LoadPreExistingOrdersCopyWithImpl(this._self, this._then); + + final LoadPreExistingOrders _self; + final $Res Function(LoadPreExistingOrders) _then; + +/// Create a copy of KioskHomeEvent +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') $Res call({Object? orders = null,}) { + return _then(LoadPreExistingOrders( +null == orders ? _self._orders : orders // ignore: cast_nullable_to_non_nullable +as List, + )); +} + + +} + +/// @nodoc + + +class ApplyPreExistingOrder implements KioskHomeEvent { + const ApplyPreExistingOrder(this.order, this.metadata); + + + final LatteOrder order; + final LatteOrderMetadata metadata; + +/// Create a copy of KioskHomeEvent +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$ApplyPreExistingOrderCopyWith get copyWith => _$ApplyPreExistingOrderCopyWithImpl(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is ApplyPreExistingOrder&&(identical(other.order, order) || other.order == order)&&(identical(other.metadata, metadata) || other.metadata == metadata)); +} + + +@override +int get hashCode => Object.hash(runtimeType,order,metadata); + +@override +String toString() { + return 'KioskHomeEvent.applyPreExistingOrder(order: $order, metadata: $metadata)'; +} + + +} + +/// @nodoc +abstract mixin class $ApplyPreExistingOrderCopyWith<$Res> implements $KioskHomeEventCopyWith<$Res> { + factory $ApplyPreExistingOrderCopyWith(ApplyPreExistingOrder value, $Res Function(ApplyPreExistingOrder) _then) = _$ApplyPreExistingOrderCopyWithImpl; +@useResult +$Res call({ + LatteOrder order, LatteOrderMetadata metadata +}); + + +$LatteOrderCopyWith<$Res> get order;$LatteOrderMetadataCopyWith<$Res> get metadata; + +} +/// @nodoc +class _$ApplyPreExistingOrderCopyWithImpl<$Res> + implements $ApplyPreExistingOrderCopyWith<$Res> { + _$ApplyPreExistingOrderCopyWithImpl(this._self, this._then); + + final ApplyPreExistingOrder _self; + final $Res Function(ApplyPreExistingOrder) _then; + +/// Create a copy of KioskHomeEvent +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') $Res call({Object? order = null,Object? metadata = null,}) { + return _then(ApplyPreExistingOrder( +null == order ? _self.order : order // ignore: cast_nullable_to_non_nullable +as LatteOrder,null == metadata ? _self.metadata : metadata // ignore: cast_nullable_to_non_nullable +as LatteOrderMetadata, + )); +} + +/// Create a copy of KioskHomeEvent +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$LatteOrderCopyWith<$Res> get order { + + return $LatteOrderCopyWith<$Res>(_self.order, (value) { + return _then(_self.copyWith(order: value)); + }); +}/// Create a copy of KioskHomeEvent +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$LatteOrderMetadataCopyWith<$Res> get metadata { + + return $LatteOrderMetadataCopyWith<$Res>(_self.metadata, (value) { + return _then(_self.copyWith(metadata: value)); + }); +} +} + +/// @nodoc + + +class UpdateMilkOptions implements KioskHomeEvent { + const UpdateMilkOptions(final List? milkOptions): _milkOptions = milkOptions; + + + final List? _milkOptions; + List? get milkOptions { + final value = _milkOptions; + if (value == null) return null; + if (_milkOptions is EqualUnmodifiableListView) return _milkOptions; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(value); +} + + +/// Create a copy of KioskHomeEvent +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$UpdateMilkOptionsCopyWith get copyWith => _$UpdateMilkOptionsCopyWithImpl(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is UpdateMilkOptions&&const DeepCollectionEquality().equals(other._milkOptions, _milkOptions)); +} + + +@override +int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(_milkOptions)); + +@override +String toString() { + return 'KioskHomeEvent.updateMilkOptions(milkOptions: $milkOptions)'; +} + + +} + +/// @nodoc +abstract mixin class $UpdateMilkOptionsCopyWith<$Res> implements $KioskHomeEventCopyWith<$Res> { + factory $UpdateMilkOptionsCopyWith(UpdateMilkOptions value, $Res Function(UpdateMilkOptions) _then) = _$UpdateMilkOptionsCopyWithImpl; +@useResult +$Res call({ + List? milkOptions +}); + + + + +} +/// @nodoc +class _$UpdateMilkOptionsCopyWithImpl<$Res> + implements $UpdateMilkOptionsCopyWith<$Res> { + _$UpdateMilkOptionsCopyWithImpl(this._self, this._then); + + final UpdateMilkOptions _self; + final $Res Function(UpdateMilkOptions) _then; + +/// Create a copy of KioskHomeEvent +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') $Res call({Object? milkOptions = freezed,}) { + return _then(UpdateMilkOptions( +freezed == milkOptions ? _self._milkOptions : milkOptions // ignore: cast_nullable_to_non_nullable +as List?, + )); +} + + +} + +/// @nodoc + + +class UpdateSweetenerOptions implements KioskHomeEvent { + const UpdateSweetenerOptions(final List? sweetenerOptions): _sweetenerOptions = sweetenerOptions; + + + final List? _sweetenerOptions; + List? get sweetenerOptions { + final value = _sweetenerOptions; + if (value == null) return null; + if (_sweetenerOptions is EqualUnmodifiableListView) return _sweetenerOptions; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(value); +} + + +/// Create a copy of KioskHomeEvent +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$UpdateSweetenerOptionsCopyWith get copyWith => _$UpdateSweetenerOptionsCopyWithImpl(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is UpdateSweetenerOptions&&const DeepCollectionEquality().equals(other._sweetenerOptions, _sweetenerOptions)); +} + + +@override +int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(_sweetenerOptions)); + +@override +String toString() { + return 'KioskHomeEvent.updateSweetenerOptions(sweetenerOptions: $sweetenerOptions)'; +} + + +} + +/// @nodoc +abstract mixin class $UpdateSweetenerOptionsCopyWith<$Res> implements $KioskHomeEventCopyWith<$Res> { + factory $UpdateSweetenerOptionsCopyWith(UpdateSweetenerOptions value, $Res Function(UpdateSweetenerOptions) _then) = _$UpdateSweetenerOptionsCopyWithImpl; +@useResult +$Res call({ + List? sweetenerOptions +}); + + + + +} +/// @nodoc +class _$UpdateSweetenerOptionsCopyWithImpl<$Res> + implements $UpdateSweetenerOptionsCopyWith<$Res> { + _$UpdateSweetenerOptionsCopyWithImpl(this._self, this._then); + + final UpdateSweetenerOptions _self; + final $Res Function(UpdateSweetenerOptions) _then; + +/// Create a copy of KioskHomeEvent +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') $Res call({Object? sweetenerOptions = freezed,}) { + return _then(UpdateSweetenerOptions( +freezed == sweetenerOptions ? _self._sweetenerOptions : sweetenerOptions // ignore: cast_nullable_to_non_nullable +as List?, + )); +} + + +} + +/// @nodoc + + +class SelectMilk implements KioskHomeEvent { + const SelectMilk(this.milk); + + + final String milk; + +/// Create a copy of KioskHomeEvent +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$SelectMilkCopyWith get copyWith => _$SelectMilkCopyWithImpl(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is SelectMilk&&(identical(other.milk, milk) || other.milk == milk)); +} + + +@override +int get hashCode => Object.hash(runtimeType,milk); + +@override +String toString() { + return 'KioskHomeEvent.selectMilk(milk: $milk)'; +} + + +} + +/// @nodoc +abstract mixin class $SelectMilkCopyWith<$Res> implements $KioskHomeEventCopyWith<$Res> { + factory $SelectMilkCopyWith(SelectMilk value, $Res Function(SelectMilk) _then) = _$SelectMilkCopyWithImpl; +@useResult +$Res call({ + String milk +}); + + + + +} +/// @nodoc +class _$SelectMilkCopyWithImpl<$Res> + implements $SelectMilkCopyWith<$Res> { + _$SelectMilkCopyWithImpl(this._self, this._then); + + final SelectMilk _self; + final $Res Function(SelectMilk) _then; + +/// Create a copy of KioskHomeEvent +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') $Res call({Object? milk = null,}) { + return _then(SelectMilk( +null == milk ? _self.milk : milk // ignore: cast_nullable_to_non_nullable +as String, + )); +} + + +} + +/// @nodoc + + +class SelectSweetener implements KioskHomeEvent { + const SelectSweetener(this.sweetener); + + + final String sweetener; + +/// Create a copy of KioskHomeEvent +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$SelectSweetenerCopyWith get copyWith => _$SelectSweetenerCopyWithImpl(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is SelectSweetener&&(identical(other.sweetener, sweetener) || other.sweetener == sweetener)); +} + + +@override +int get hashCode => Object.hash(runtimeType,sweetener); + +@override +String toString() { + return 'KioskHomeEvent.selectSweetener(sweetener: $sweetener)'; +} + + +} + +/// @nodoc +abstract mixin class $SelectSweetenerCopyWith<$Res> implements $KioskHomeEventCopyWith<$Res> { + factory $SelectSweetenerCopyWith(SelectSweetener value, $Res Function(SelectSweetener) _then) = _$SelectSweetenerCopyWithImpl; +@useResult +$Res call({ + String sweetener +}); + + + + +} +/// @nodoc +class _$SelectSweetenerCopyWithImpl<$Res> + implements $SelectSweetenerCopyWith<$Res> { + _$SelectSweetenerCopyWithImpl(this._self, this._then); + + final SelectSweetener _self; + final $Res Function(SelectSweetener) _then; + +/// Create a copy of KioskHomeEvent +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') $Res call({Object? sweetener = null,}) { + return _then(SelectSweetener( +null == sweetener ? _self.sweetener : sweetener // ignore: cast_nullable_to_non_nullable +as String, + )); +} + + +} + +/// @nodoc + + +class SubmitMilkAndSweetener implements KioskHomeEvent { + const SubmitMilkAndSweetener(); + + + + + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is SubmitMilkAndSweetener); +} + + +@override +int get hashCode => runtimeType.hashCode; + +@override +String toString() { + return 'KioskHomeEvent.submitMilkAndSweetener()'; +} + + +} + + + + +/// @nodoc + + +class SubmitHappyPlace implements KioskHomeEvent { + const SubmitHappyPlace(this.happyPlace); + + + final String happyPlace; + +/// Create a copy of KioskHomeEvent +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$SubmitHappyPlaceCopyWith get copyWith => _$SubmitHappyPlaceCopyWithImpl(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is SubmitHappyPlace&&(identical(other.happyPlace, happyPlace) || other.happyPlace == happyPlace)); +} + + +@override +int get hashCode => Object.hash(runtimeType,happyPlace); + +@override +String toString() { + return 'KioskHomeEvent.submitHappyPlace(happyPlace: $happyPlace)'; +} + + +} + +/// @nodoc +abstract mixin class $SubmitHappyPlaceCopyWith<$Res> implements $KioskHomeEventCopyWith<$Res> { + factory $SubmitHappyPlaceCopyWith(SubmitHappyPlace value, $Res Function(SubmitHappyPlace) _then) = _$SubmitHappyPlaceCopyWithImpl; +@useResult +$Res call({ + String happyPlace +}); + + + + +} +/// @nodoc +class _$SubmitHappyPlaceCopyWithImpl<$Res> + implements $SubmitHappyPlaceCopyWith<$Res> { + _$SubmitHappyPlaceCopyWithImpl(this._self, this._then); + + final SubmitHappyPlace _self; + final $Res Function(SubmitHappyPlace) _then; + +/// Create a copy of KioskHomeEvent +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') $Res call({Object? happyPlace = null,}) { + return _then(SubmitHappyPlace( +null == happyPlace ? _self.happyPlace : happyPlace // ignore: cast_nullable_to_non_nullable +as String, + )); +} + + +} + +/// @nodoc + + +class HappyPlaceRejected implements KioskHomeEvent { + const HappyPlaceRejected(this.reason); + + + final String reason; + +/// Create a copy of KioskHomeEvent +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$HappyPlaceRejectedCopyWith get copyWith => _$HappyPlaceRejectedCopyWithImpl(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is HappyPlaceRejected&&(identical(other.reason, reason) || other.reason == reason)); +} + + +@override +int get hashCode => Object.hash(runtimeType,reason); + +@override +String toString() { + return 'KioskHomeEvent.happyPlaceRejected(reason: $reason)'; +} + + +} + +/// @nodoc +abstract mixin class $HappyPlaceRejectedCopyWith<$Res> implements $KioskHomeEventCopyWith<$Res> { + factory $HappyPlaceRejectedCopyWith(HappyPlaceRejected value, $Res Function(HappyPlaceRejected) _then) = _$HappyPlaceRejectedCopyWithImpl; +@useResult +$Res call({ + String reason +}); + + + + +} +/// @nodoc +class _$HappyPlaceRejectedCopyWithImpl<$Res> + implements $HappyPlaceRejectedCopyWith<$Res> { + _$HappyPlaceRejectedCopyWithImpl(this._self, this._then); + + final HappyPlaceRejected _self; + final $Res Function(HappyPlaceRejected) _then; + +/// Create a copy of KioskHomeEvent +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') $Res call({Object? reason = null,}) { + return _then(HappyPlaceRejected( +null == reason ? _self.reason : reason // ignore: cast_nullable_to_non_nullable +as String, + )); +} + + +} + +/// @nodoc + + +class HappyPlaceModerationReasonShown implements KioskHomeEvent { + const HappyPlaceModerationReasonShown(); + + + + + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is HappyPlaceModerationReasonShown); +} + + +@override +int get hashCode => runtimeType.hashCode; + +@override +String toString() { + return 'KioskHomeEvent.happyPlaceModerationReasonShown()'; +} + + +} + + + + +/// @nodoc + + +class ServerOrderUpdate implements KioskHomeEvent { + const ServerOrderUpdate(this.order); + + + final LatteOrder? order; + +/// Create a copy of KioskHomeEvent +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$ServerOrderUpdateCopyWith get copyWith => _$ServerOrderUpdateCopyWithImpl(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is ServerOrderUpdate&&(identical(other.order, order) || other.order == order)); +} + + +@override +int get hashCode => Object.hash(runtimeType,order); + +@override +String toString() { + return 'KioskHomeEvent.serverOrderUpdate(order: $order)'; +} + + +} + +/// @nodoc +abstract mixin class $ServerOrderUpdateCopyWith<$Res> implements $KioskHomeEventCopyWith<$Res> { + factory $ServerOrderUpdateCopyWith(ServerOrderUpdate value, $Res Function(ServerOrderUpdate) _then) = _$ServerOrderUpdateCopyWithImpl; +@useResult +$Res call({ + LatteOrder? order +}); + + +$LatteOrderCopyWith<$Res>? get order; + +} +/// @nodoc +class _$ServerOrderUpdateCopyWithImpl<$Res> + implements $ServerOrderUpdateCopyWith<$Res> { + _$ServerOrderUpdateCopyWithImpl(this._self, this._then); + + final ServerOrderUpdate _self; + final $Res Function(ServerOrderUpdate) _then; + +/// Create a copy of KioskHomeEvent +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') $Res call({Object? order = freezed,}) { + return _then(ServerOrderUpdate( +freezed == order ? _self.order : order // ignore: cast_nullable_to_non_nullable +as LatteOrder?, + )); +} + +/// Create a copy of KioskHomeEvent +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$LatteOrderCopyWith<$Res>? get order { + if (_self.order == null) { + return null; + } + + return $LatteOrderCopyWith<$Res>(_self.order!, (value) { + return _then(_self.copyWith(order: value)); + }); +} +} + +/// @nodoc + + +class ServerMetadataUpdate implements KioskHomeEvent { + const ServerMetadataUpdate(this.metadata); + + + final LatteOrderMetadata? metadata; + +/// Create a copy of KioskHomeEvent +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$ServerMetadataUpdateCopyWith get copyWith => _$ServerMetadataUpdateCopyWithImpl(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is ServerMetadataUpdate&&(identical(other.metadata, metadata) || other.metadata == metadata)); +} + + +@override +int get hashCode => Object.hash(runtimeType,metadata); + +@override +String toString() { + return 'KioskHomeEvent.serverMetadataUpdate(metadata: $metadata)'; +} + + +} + +/// @nodoc +abstract mixin class $ServerMetadataUpdateCopyWith<$Res> implements $KioskHomeEventCopyWith<$Res> { + factory $ServerMetadataUpdateCopyWith(ServerMetadataUpdate value, $Res Function(ServerMetadataUpdate) _then) = _$ServerMetadataUpdateCopyWithImpl; +@useResult +$Res call({ + LatteOrderMetadata? metadata +}); + + +$LatteOrderMetadataCopyWith<$Res>? get metadata; + +} +/// @nodoc +class _$ServerMetadataUpdateCopyWithImpl<$Res> + implements $ServerMetadataUpdateCopyWith<$Res> { + _$ServerMetadataUpdateCopyWithImpl(this._self, this._then); + + final ServerMetadataUpdate _self; + final $Res Function(ServerMetadataUpdate) _then; + +/// Create a copy of KioskHomeEvent +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') $Res call({Object? metadata = freezed,}) { + return _then(ServerMetadataUpdate( +freezed == metadata ? _self.metadata : metadata // ignore: cast_nullable_to_non_nullable +as LatteOrderMetadata?, + )); +} + +/// Create a copy of KioskHomeEvent +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$LatteOrderMetadataCopyWith<$Res>? get metadata { + if (_self.metadata == null) { + return null; + } + + return $LatteOrderMetadataCopyWith<$Res>(_self.metadata!, (value) { + return _then(_self.copyWith(metadata: value)); + }); +} +} + +/// @nodoc + + +class GoBackKioskWizard implements KioskHomeEvent { + const GoBackKioskWizard(); + + + + + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is GoBackKioskWizard); +} + + +@override +int get hashCode => runtimeType.hashCode; + +@override +String toString() { + return 'KioskHomeEvent.goBack()'; +} + + +} + + + + +/// @nodoc + + +class GoToStep implements KioskHomeEvent { + const GoToStep(this.step); + + + final KioskWizardStep step; + +/// Create a copy of KioskHomeEvent +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$GoToStepCopyWith get copyWith => _$GoToStepCopyWithImpl(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is GoToStep&&(identical(other.step, step) || other.step == step)); +} + + +@override +int get hashCode => Object.hash(runtimeType,step); + +@override +String toString() { + return 'KioskHomeEvent.goToStep(step: $step)'; +} + + +} + +/// @nodoc +abstract mixin class $GoToStepCopyWith<$Res> implements $KioskHomeEventCopyWith<$Res> { + factory $GoToStepCopyWith(GoToStep value, $Res Function(GoToStep) _then) = _$GoToStepCopyWithImpl; +@useResult +$Res call({ + KioskWizardStep step +}); + + + + +} +/// @nodoc +class _$GoToStepCopyWithImpl<$Res> + implements $GoToStepCopyWith<$Res> { + _$GoToStepCopyWithImpl(this._self, this._then); + + final GoToStep _self; + final $Res Function(GoToStep) _then; + +/// Create a copy of KioskHomeEvent +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') $Res call({Object? step = null,}) { + return _then(GoToStep( +null == step ? _self.step : step // ignore: cast_nullable_to_non_nullable +as KioskWizardStep, + )); +} + + +} + +/// @nodoc + + +class SubmitUserName implements KioskHomeEvent { + const SubmitUserName(this.name); + + + final String name; + +/// Create a copy of KioskHomeEvent +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$SubmitUserNameCopyWith get copyWith => _$SubmitUserNameCopyWithImpl(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is SubmitUserName&&(identical(other.name, name) || other.name == name)); +} + + +@override +int get hashCode => Object.hash(runtimeType,name); + +@override +String toString() { + return 'KioskHomeEvent.submitUserName(name: $name)'; +} + + +} + +/// @nodoc +abstract mixin class $SubmitUserNameCopyWith<$Res> implements $KioskHomeEventCopyWith<$Res> { + factory $SubmitUserNameCopyWith(SubmitUserName value, $Res Function(SubmitUserName) _then) = _$SubmitUserNameCopyWithImpl; +@useResult +$Res call({ + String name +}); + + + + +} +/// @nodoc +class _$SubmitUserNameCopyWithImpl<$Res> + implements $SubmitUserNameCopyWith<$Res> { + _$SubmitUserNameCopyWithImpl(this._self, this._then); + + final SubmitUserName _self; + final $Res Function(SubmitUserName) _then; + +/// Create a copy of KioskHomeEvent +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') $Res call({Object? name = null,}) { + return _then(SubmitUserName( +null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable +as String, + )); +} + + +} + +/// @nodoc + + +class SelectImage implements KioskHomeEvent { + const SelectImage(this.index); + + + final int index; + +/// Create a copy of KioskHomeEvent +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$SelectImageCopyWith get copyWith => _$SelectImageCopyWithImpl(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is SelectImage&&(identical(other.index, index) || other.index == index)); +} + + +@override +int get hashCode => Object.hash(runtimeType,index); + +@override +String toString() { + return 'KioskHomeEvent.selectImage(index: $index)'; +} + + +} + +/// @nodoc +abstract mixin class $SelectImageCopyWith<$Res> implements $KioskHomeEventCopyWith<$Res> { + factory $SelectImageCopyWith(SelectImage value, $Res Function(SelectImage) _then) = _$SelectImageCopyWithImpl; +@useResult +$Res call({ + int index +}); + + + + +} +/// @nodoc +class _$SelectImageCopyWithImpl<$Res> + implements $SelectImageCopyWith<$Res> { + _$SelectImageCopyWithImpl(this._self, this._then); + + final SelectImage _self; + final $Res Function(SelectImage) _then; + +/// Create a copy of KioskHomeEvent +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') $Res call({Object? index = null,}) { + return _then(SelectImage( +null == index ? _self.index : index // ignore: cast_nullable_to_non_nullable +as int, + )); +} + + +} + +/// @nodoc + + +class UpdateLatteImages implements KioskHomeEvent { + const UpdateLatteImages(this.batch); + + + final LatteImageBatch? batch; + +/// Create a copy of KioskHomeEvent +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$UpdateLatteImagesCopyWith get copyWith => _$UpdateLatteImagesCopyWithImpl(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is UpdateLatteImages&&(identical(other.batch, batch) || other.batch == batch)); +} + + +@override +int get hashCode => Object.hash(runtimeType,batch); + +@override +String toString() { + return 'KioskHomeEvent.updateLatteImages(batch: $batch)'; +} + + +} + +/// @nodoc +abstract mixin class $UpdateLatteImagesCopyWith<$Res> implements $KioskHomeEventCopyWith<$Res> { + factory $UpdateLatteImagesCopyWith(UpdateLatteImages value, $Res Function(UpdateLatteImages) _then) = _$UpdateLatteImagesCopyWithImpl; +@useResult +$Res call({ + LatteImageBatch? batch +}); + + +$LatteImageBatchCopyWith<$Res>? get batch; + +} +/// @nodoc +class _$UpdateLatteImagesCopyWithImpl<$Res> + implements $UpdateLatteImagesCopyWith<$Res> { + _$UpdateLatteImagesCopyWithImpl(this._self, this._then); + + final UpdateLatteImages _self; + final $Res Function(UpdateLatteImages) _then; + +/// Create a copy of KioskHomeEvent +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') $Res call({Object? batch = freezed,}) { + return _then(UpdateLatteImages( +freezed == batch ? _self.batch : batch // ignore: cast_nullable_to_non_nullable +as LatteImageBatch?, + )); +} + +/// Create a copy of KioskHomeEvent +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$LatteImageBatchCopyWith<$Res>? get batch { + if (_self.batch == null) { + return null; + } + + return $LatteImageBatchCopyWith<$Res>(_self.batch!, (value) { + return _then(_self.copyWith(batch: value)); + }); +} +} + +/// @nodoc + + +class NewImageLatteBatch implements KioskHomeEvent { + const NewImageLatteBatch(this.latteImageBatchId); + + + final String latteImageBatchId; + +/// Create a copy of KioskHomeEvent +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$NewImageLatteBatchCopyWith get copyWith => _$NewImageLatteBatchCopyWithImpl(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is NewImageLatteBatch&&(identical(other.latteImageBatchId, latteImageBatchId) || other.latteImageBatchId == latteImageBatchId)); +} + + +@override +int get hashCode => Object.hash(runtimeType,latteImageBatchId); + +@override +String toString() { + return 'KioskHomeEvent.newImageLatteBatch(latteImageBatchId: $latteImageBatchId)'; +} + + +} + +/// @nodoc +abstract mixin class $NewImageLatteBatchCopyWith<$Res> implements $KioskHomeEventCopyWith<$Res> { + factory $NewImageLatteBatchCopyWith(NewImageLatteBatch value, $Res Function(NewImageLatteBatch) _then) = _$NewImageLatteBatchCopyWithImpl; +@useResult +$Res call({ + String latteImageBatchId +}); + + + + +} +/// @nodoc +class _$NewImageLatteBatchCopyWithImpl<$Res> + implements $NewImageLatteBatchCopyWith<$Res> { + _$NewImageLatteBatchCopyWithImpl(this._self, this._then); + + final NewImageLatteBatch _self; + final $Res Function(NewImageLatteBatch) _then; + +/// Create a copy of KioskHomeEvent +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') $Res call({Object? latteImageBatchId = null,}) { + return _then(NewImageLatteBatch( +null == latteImageBatchId ? _self.latteImageBatchId : latteImageBatchId // ignore: cast_nullable_to_non_nullable +as String, + )); +} + + +} + +/// @nodoc + + +class UpdateQuestions implements KioskHomeEvent { + const UpdateQuestions(final List questions): _questions = questions; + + + final List _questions; + List get questions { + if (_questions is EqualUnmodifiableListView) return _questions; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_questions); +} + + +/// Create a copy of KioskHomeEvent +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$UpdateQuestionsCopyWith get copyWith => _$UpdateQuestionsCopyWithImpl(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is UpdateQuestions&&const DeepCollectionEquality().equals(other._questions, _questions)); +} + + +@override +int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(_questions)); + +@override +String toString() { + return 'KioskHomeEvent.updateQuestions(questions: $questions)'; +} + + +} + +/// @nodoc +abstract mixin class $UpdateQuestionsCopyWith<$Res> implements $KioskHomeEventCopyWith<$Res> { + factory $UpdateQuestionsCopyWith(UpdateQuestions value, $Res Function(UpdateQuestions) _then) = _$UpdateQuestionsCopyWithImpl; +@useResult +$Res call({ + List questions +}); + + + + +} +/// @nodoc +class _$UpdateQuestionsCopyWithImpl<$Res> + implements $UpdateQuestionsCopyWith<$Res> { + _$UpdateQuestionsCopyWithImpl(this._self, this._then); + + final UpdateQuestions _self; + final $Res Function(UpdateQuestions) _then; + +/// Create a copy of KioskHomeEvent +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') $Res call({Object? questions = null,}) { + return _then(UpdateQuestions( +null == questions ? _self._questions : questions // ignore: cast_nullable_to_non_nullable +as List, + )); +} + + +} + +/// @nodoc + + +class AnswerQuestion implements KioskHomeEvent { + const AnswerQuestion(this.question, this.answer); + + + final Question question; + final Object? answer; + +/// Create a copy of KioskHomeEvent +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$AnswerQuestionCopyWith get copyWith => _$AnswerQuestionCopyWithImpl(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is AnswerQuestion&&(identical(other.question, question) || other.question == question)&&const DeepCollectionEquality().equals(other.answer, answer)); +} + + +@override +int get hashCode => Object.hash(runtimeType,question,const DeepCollectionEquality().hash(answer)); + +@override +String toString() { + return 'KioskHomeEvent.answerQuestion(question: $question, answer: $answer)'; +} + + +} + +/// @nodoc +abstract mixin class $AnswerQuestionCopyWith<$Res> implements $KioskHomeEventCopyWith<$Res> { + factory $AnswerQuestionCopyWith(AnswerQuestion value, $Res Function(AnswerQuestion) _then) = _$AnswerQuestionCopyWithImpl; +@useResult +$Res call({ + Question question, Object? answer +}); + + +$QuestionCopyWith<$Res> get question; + +} +/// @nodoc +class _$AnswerQuestionCopyWithImpl<$Res> + implements $AnswerQuestionCopyWith<$Res> { + _$AnswerQuestionCopyWithImpl(this._self, this._then); + + final AnswerQuestion _self; + final $Res Function(AnswerQuestion) _then; + +/// Create a copy of KioskHomeEvent +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') $Res call({Object? question = null,Object? answer = freezed,}) { + return _then(AnswerQuestion( +null == question ? _self.question : question // ignore: cast_nullable_to_non_nullable +as Question,freezed == answer ? _self.answer : answer , + )); +} + +/// Create a copy of KioskHomeEvent +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$QuestionCopyWith<$Res> get question { + + return $QuestionCopyWith<$Res>(_self.question, (value) { + return _then(_self.copyWith(question: value)); + }); +} +} + +/// @nodoc + + +class GenerateRevisedImages implements KioskHomeEvent { + const GenerateRevisedImages(); + + + + + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is GenerateRevisedImages); +} + + +@override +int get hashCode => runtimeType.hashCode; + +@override +String toString() { + return 'KioskHomeEvent.generateRevisedImages()'; +} + + +} + + + + +/// @nodoc + + +class RejectImageBatch implements KioskHomeEvent { + const RejectImageBatch(); + + + + + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is RejectImageBatch); +} + + +@override +int get hashCode => runtimeType.hashCode; + +@override +String toString() { + return 'KioskHomeEvent.rejectImageBatch()'; +} + + +} + + + + +/// @nodoc + + +class AcceptImage implements KioskHomeEvent { + const AcceptImage(); + + + + + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is AcceptImage); +} + + +@override +int get hashCode => runtimeType.hashCode; + +@override +String toString() { + return 'KioskHomeEvent.acceptImage()'; +} + + +} + + + + +/// @nodoc + + +class SubmitOrder implements KioskHomeEvent { + const SubmitOrder(); + + + + + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is SubmitOrder); +} + + +@override +int get hashCode => runtimeType.hashCode; + +@override +String toString() { + return 'KioskHomeEvent.submitOrder()'; +} + + +} + + + + +/// @nodoc +mixin _$KioskHomeState { + + KioskWizardStep get currentStep; LatteOrder? get order; LatteOrderMetadata? get metadata; ModerationEvent? get happyPlaceModerationEvent; bool get isSubmitting; bool get isReverting;/// When the user submits a happy place, or a tweak, +/// the clock starts ticking on image generation. +/// We want to track this time. If for nothing else, it helps us show +/// a progress bar that doesn't depend on widget states being mounted +/// throughout the image generation without gaps. + DateTime? get imageGenerationStartTime;/// When an image's questions are requested before they exist, we store the +/// need here so that we add them to the state once they arrive. + bool get awaitingQuestions;/// The current batch of 4 images to choose from. This is deduced by the +/// [LatteOrderMetadata]'s `latteBatchId` field. + LatteImageBatch? get imagesBatch;/// Copy of the active [LatteImage]'s questions, only set once the user +/// chooses an image and clicks the "tweak image" button. This copy is used +/// instead of the questions in the [LatteImage] to isolate the user's +/// answers from changes to the [LatteImageBatch]. The critical detail is +/// that answers are not persisted to Firebase, which means anything that +/// overwrites the [LatteImageBatch] would clobber the user's answers. + List get questions;/// Values loaded straight from Firebase. +@LatteOptionConverter() List? get milkOptions;/// Values loaded straight from Firebase. +@LatteOptionConverter() List? get sweetenerOptions;/// True if the user is starting over and we want to eject data upon +/// returning to the .intro screen. + bool get shouldClearData; int? get selectedImageIndex; +/// Create a copy of KioskHomeState +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$KioskHomeStateCopyWith get copyWith => _$KioskHomeStateCopyWithImpl(this as KioskHomeState, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is KioskHomeState&&(identical(other.currentStep, currentStep) || other.currentStep == currentStep)&&(identical(other.order, order) || other.order == order)&&(identical(other.metadata, metadata) || other.metadata == metadata)&&(identical(other.happyPlaceModerationEvent, happyPlaceModerationEvent) || other.happyPlaceModerationEvent == happyPlaceModerationEvent)&&(identical(other.isSubmitting, isSubmitting) || other.isSubmitting == isSubmitting)&&(identical(other.isReverting, isReverting) || other.isReverting == isReverting)&&(identical(other.imageGenerationStartTime, imageGenerationStartTime) || other.imageGenerationStartTime == imageGenerationStartTime)&&(identical(other.awaitingQuestions, awaitingQuestions) || other.awaitingQuestions == awaitingQuestions)&&(identical(other.imagesBatch, imagesBatch) || other.imagesBatch == imagesBatch)&&const DeepCollectionEquality().equals(other.questions, questions)&&const DeepCollectionEquality().equals(other.milkOptions, milkOptions)&&const DeepCollectionEquality().equals(other.sweetenerOptions, sweetenerOptions)&&(identical(other.shouldClearData, shouldClearData) || other.shouldClearData == shouldClearData)&&(identical(other.selectedImageIndex, selectedImageIndex) || other.selectedImageIndex == selectedImageIndex)); +} + + +@override +int get hashCode => Object.hash(runtimeType,currentStep,order,metadata,happyPlaceModerationEvent,isSubmitting,isReverting,imageGenerationStartTime,awaitingQuestions,imagesBatch,const DeepCollectionEquality().hash(questions),const DeepCollectionEquality().hash(milkOptions),const DeepCollectionEquality().hash(sweetenerOptions),shouldClearData,selectedImageIndex); + +@override +String toString() { + return 'KioskHomeState(currentStep: $currentStep, order: $order, metadata: $metadata, happyPlaceModerationEvent: $happyPlaceModerationEvent, isSubmitting: $isSubmitting, isReverting: $isReverting, imageGenerationStartTime: $imageGenerationStartTime, awaitingQuestions: $awaitingQuestions, imagesBatch: $imagesBatch, questions: $questions, milkOptions: $milkOptions, sweetenerOptions: $sweetenerOptions, shouldClearData: $shouldClearData, selectedImageIndex: $selectedImageIndex)'; +} + + +} + +/// @nodoc +abstract mixin class $KioskHomeStateCopyWith<$Res> { + factory $KioskHomeStateCopyWith(KioskHomeState value, $Res Function(KioskHomeState) _then) = _$KioskHomeStateCopyWithImpl; +@useResult +$Res call({ + KioskWizardStep currentStep, LatteOrder? order, LatteOrderMetadata? metadata, ModerationEvent? happyPlaceModerationEvent, bool isSubmitting, bool isReverting, DateTime? imageGenerationStartTime, bool awaitingQuestions, LatteImageBatch? imagesBatch, List questions,@LatteOptionConverter() List? milkOptions,@LatteOptionConverter() List? sweetenerOptions, bool shouldClearData, int? selectedImageIndex +}); + + +$LatteOrderCopyWith<$Res>? get order;$LatteOrderMetadataCopyWith<$Res>? get metadata;$LatteImageBatchCopyWith<$Res>? get imagesBatch; + +} +/// @nodoc +class _$KioskHomeStateCopyWithImpl<$Res> + implements $KioskHomeStateCopyWith<$Res> { + _$KioskHomeStateCopyWithImpl(this._self, this._then); + + final KioskHomeState _self; + final $Res Function(KioskHomeState) _then; + +/// Create a copy of KioskHomeState +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? currentStep = null,Object? order = freezed,Object? metadata = freezed,Object? happyPlaceModerationEvent = freezed,Object? isSubmitting = null,Object? isReverting = null,Object? imageGenerationStartTime = freezed,Object? awaitingQuestions = null,Object? imagesBatch = freezed,Object? questions = null,Object? milkOptions = freezed,Object? sweetenerOptions = freezed,Object? shouldClearData = null,Object? selectedImageIndex = freezed,}) { + return _then(_self.copyWith( +currentStep: null == currentStep ? _self.currentStep : currentStep // ignore: cast_nullable_to_non_nullable +as KioskWizardStep,order: freezed == order ? _self.order : order // ignore: cast_nullable_to_non_nullable +as LatteOrder?,metadata: freezed == metadata ? _self.metadata : metadata // ignore: cast_nullable_to_non_nullable +as LatteOrderMetadata?,happyPlaceModerationEvent: freezed == happyPlaceModerationEvent ? _self.happyPlaceModerationEvent : happyPlaceModerationEvent // ignore: cast_nullable_to_non_nullable +as ModerationEvent?,isSubmitting: null == isSubmitting ? _self.isSubmitting : isSubmitting // ignore: cast_nullable_to_non_nullable +as bool,isReverting: null == isReverting ? _self.isReverting : isReverting // ignore: cast_nullable_to_non_nullable +as bool,imageGenerationStartTime: freezed == imageGenerationStartTime ? _self.imageGenerationStartTime : imageGenerationStartTime // ignore: cast_nullable_to_non_nullable +as DateTime?,awaitingQuestions: null == awaitingQuestions ? _self.awaitingQuestions : awaitingQuestions // ignore: cast_nullable_to_non_nullable +as bool,imagesBatch: freezed == imagesBatch ? _self.imagesBatch : imagesBatch // ignore: cast_nullable_to_non_nullable +as LatteImageBatch?,questions: null == questions ? _self.questions : questions // ignore: cast_nullable_to_non_nullable +as List,milkOptions: freezed == milkOptions ? _self.milkOptions : milkOptions // ignore: cast_nullable_to_non_nullable +as List?,sweetenerOptions: freezed == sweetenerOptions ? _self.sweetenerOptions : sweetenerOptions // ignore: cast_nullable_to_non_nullable +as List?,shouldClearData: null == shouldClearData ? _self.shouldClearData : shouldClearData // ignore: cast_nullable_to_non_nullable +as bool,selectedImageIndex: freezed == selectedImageIndex ? _self.selectedImageIndex : selectedImageIndex // ignore: cast_nullable_to_non_nullable +as int?, + )); +} +/// Create a copy of KioskHomeState +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$LatteOrderCopyWith<$Res>? get order { + if (_self.order == null) { + return null; + } + + return $LatteOrderCopyWith<$Res>(_self.order!, (value) { + return _then(_self.copyWith(order: value)); + }); +}/// Create a copy of KioskHomeState +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$LatteOrderMetadataCopyWith<$Res>? get metadata { + if (_self.metadata == null) { + return null; + } + + return $LatteOrderMetadataCopyWith<$Res>(_self.metadata!, (value) { + return _then(_self.copyWith(metadata: value)); + }); +}/// Create a copy of KioskHomeState +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$LatteImageBatchCopyWith<$Res>? get imagesBatch { + if (_self.imagesBatch == null) { + return null; + } + + return $LatteImageBatchCopyWith<$Res>(_self.imagesBatch!, (value) { + return _then(_self.copyWith(imagesBatch: value)); + }); +} +} + + +/// Adds pattern-matching-related methods to [KioskHomeState]. +extension KioskHomeStatePatterns on KioskHomeState { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _KioskHomeState value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _KioskHomeState() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _KioskHomeState value) $default,){ +final _that = this; +switch (_that) { +case _KioskHomeState(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _KioskHomeState value)? $default,){ +final _that = this; +switch (_that) { +case _KioskHomeState() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( KioskWizardStep currentStep, LatteOrder? order, LatteOrderMetadata? metadata, ModerationEvent? happyPlaceModerationEvent, bool isSubmitting, bool isReverting, DateTime? imageGenerationStartTime, bool awaitingQuestions, LatteImageBatch? imagesBatch, List questions, @LatteOptionConverter() List? milkOptions, @LatteOptionConverter() List? sweetenerOptions, bool shouldClearData, int? selectedImageIndex)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _KioskHomeState() when $default != null: +return $default(_that.currentStep,_that.order,_that.metadata,_that.happyPlaceModerationEvent,_that.isSubmitting,_that.isReverting,_that.imageGenerationStartTime,_that.awaitingQuestions,_that.imagesBatch,_that.questions,_that.milkOptions,_that.sweetenerOptions,_that.shouldClearData,_that.selectedImageIndex);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( KioskWizardStep currentStep, LatteOrder? order, LatteOrderMetadata? metadata, ModerationEvent? happyPlaceModerationEvent, bool isSubmitting, bool isReverting, DateTime? imageGenerationStartTime, bool awaitingQuestions, LatteImageBatch? imagesBatch, List questions, @LatteOptionConverter() List? milkOptions, @LatteOptionConverter() List? sweetenerOptions, bool shouldClearData, int? selectedImageIndex) $default,) {final _that = this; +switch (_that) { +case _KioskHomeState(): +return $default(_that.currentStep,_that.order,_that.metadata,_that.happyPlaceModerationEvent,_that.isSubmitting,_that.isReverting,_that.imageGenerationStartTime,_that.awaitingQuestions,_that.imagesBatch,_that.questions,_that.milkOptions,_that.sweetenerOptions,_that.shouldClearData,_that.selectedImageIndex);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( KioskWizardStep currentStep, LatteOrder? order, LatteOrderMetadata? metadata, ModerationEvent? happyPlaceModerationEvent, bool isSubmitting, bool isReverting, DateTime? imageGenerationStartTime, bool awaitingQuestions, LatteImageBatch? imagesBatch, List questions, @LatteOptionConverter() List? milkOptions, @LatteOptionConverter() List? sweetenerOptions, bool shouldClearData, int? selectedImageIndex)? $default,) {final _that = this; +switch (_that) { +case _KioskHomeState() when $default != null: +return $default(_that.currentStep,_that.order,_that.metadata,_that.happyPlaceModerationEvent,_that.isSubmitting,_that.isReverting,_that.imageGenerationStartTime,_that.awaitingQuestions,_that.imagesBatch,_that.questions,_that.milkOptions,_that.sweetenerOptions,_that.shouldClearData,_that.selectedImageIndex);case _: + return null; + +} +} + +} + +/// @nodoc + + +class _KioskHomeState extends KioskHomeState { + const _KioskHomeState({required this.currentStep, this.order, this.metadata, this.happyPlaceModerationEvent, this.isSubmitting = false, this.isReverting = false, this.imageGenerationStartTime, this.awaitingQuestions = false, this.imagesBatch, final List questions = const [], @LatteOptionConverter() final List? milkOptions, @LatteOptionConverter() final List? sweetenerOptions, this.shouldClearData = false, this.selectedImageIndex}): _questions = questions,_milkOptions = milkOptions,_sweetenerOptions = sweetenerOptions,super._(); + + +@override final KioskWizardStep currentStep; +@override final LatteOrder? order; +@override final LatteOrderMetadata? metadata; +@override final ModerationEvent? happyPlaceModerationEvent; +@override@JsonKey() final bool isSubmitting; +@override@JsonKey() final bool isReverting; +/// When the user submits a happy place, or a tweak, +/// the clock starts ticking on image generation. +/// We want to track this time. If for nothing else, it helps us show +/// a progress bar that doesn't depend on widget states being mounted +/// throughout the image generation without gaps. +@override final DateTime? imageGenerationStartTime; +/// When an image's questions are requested before they exist, we store the +/// need here so that we add them to the state once they arrive. +@override@JsonKey() final bool awaitingQuestions; +/// The current batch of 4 images to choose from. This is deduced by the +/// [LatteOrderMetadata]'s `latteBatchId` field. +@override final LatteImageBatch? imagesBatch; +/// Copy of the active [LatteImage]'s questions, only set once the user +/// chooses an image and clicks the "tweak image" button. This copy is used +/// instead of the questions in the [LatteImage] to isolate the user's +/// answers from changes to the [LatteImageBatch]. The critical detail is +/// that answers are not persisted to Firebase, which means anything that +/// overwrites the [LatteImageBatch] would clobber the user's answers. + final List _questions; +/// Copy of the active [LatteImage]'s questions, only set once the user +/// chooses an image and clicks the "tweak image" button. This copy is used +/// instead of the questions in the [LatteImage] to isolate the user's +/// answers from changes to the [LatteImageBatch]. The critical detail is +/// that answers are not persisted to Firebase, which means anything that +/// overwrites the [LatteImageBatch] would clobber the user's answers. +@override@JsonKey() List get questions { + if (_questions is EqualUnmodifiableListView) return _questions; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_questions); +} + +/// Values loaded straight from Firebase. + final List? _milkOptions; +/// Values loaded straight from Firebase. +@override@LatteOptionConverter() List? get milkOptions { + final value = _milkOptions; + if (value == null) return null; + if (_milkOptions is EqualUnmodifiableListView) return _milkOptions; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(value); +} + +/// Values loaded straight from Firebase. + final List? _sweetenerOptions; +/// Values loaded straight from Firebase. +@override@LatteOptionConverter() List? get sweetenerOptions { + final value = _sweetenerOptions; + if (value == null) return null; + if (_sweetenerOptions is EqualUnmodifiableListView) return _sweetenerOptions; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(value); +} + +/// True if the user is starting over and we want to eject data upon +/// returning to the .intro screen. +@override@JsonKey() final bool shouldClearData; +@override final int? selectedImageIndex; + +/// Create a copy of KioskHomeState +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$KioskHomeStateCopyWith<_KioskHomeState> get copyWith => __$KioskHomeStateCopyWithImpl<_KioskHomeState>(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _KioskHomeState&&(identical(other.currentStep, currentStep) || other.currentStep == currentStep)&&(identical(other.order, order) || other.order == order)&&(identical(other.metadata, metadata) || other.metadata == metadata)&&(identical(other.happyPlaceModerationEvent, happyPlaceModerationEvent) || other.happyPlaceModerationEvent == happyPlaceModerationEvent)&&(identical(other.isSubmitting, isSubmitting) || other.isSubmitting == isSubmitting)&&(identical(other.isReverting, isReverting) || other.isReverting == isReverting)&&(identical(other.imageGenerationStartTime, imageGenerationStartTime) || other.imageGenerationStartTime == imageGenerationStartTime)&&(identical(other.awaitingQuestions, awaitingQuestions) || other.awaitingQuestions == awaitingQuestions)&&(identical(other.imagesBatch, imagesBatch) || other.imagesBatch == imagesBatch)&&const DeepCollectionEquality().equals(other._questions, _questions)&&const DeepCollectionEquality().equals(other._milkOptions, _milkOptions)&&const DeepCollectionEquality().equals(other._sweetenerOptions, _sweetenerOptions)&&(identical(other.shouldClearData, shouldClearData) || other.shouldClearData == shouldClearData)&&(identical(other.selectedImageIndex, selectedImageIndex) || other.selectedImageIndex == selectedImageIndex)); +} + + +@override +int get hashCode => Object.hash(runtimeType,currentStep,order,metadata,happyPlaceModerationEvent,isSubmitting,isReverting,imageGenerationStartTime,awaitingQuestions,imagesBatch,const DeepCollectionEquality().hash(_questions),const DeepCollectionEquality().hash(_milkOptions),const DeepCollectionEquality().hash(_sweetenerOptions),shouldClearData,selectedImageIndex); + +@override +String toString() { + return 'KioskHomeState(currentStep: $currentStep, order: $order, metadata: $metadata, happyPlaceModerationEvent: $happyPlaceModerationEvent, isSubmitting: $isSubmitting, isReverting: $isReverting, imageGenerationStartTime: $imageGenerationStartTime, awaitingQuestions: $awaitingQuestions, imagesBatch: $imagesBatch, questions: $questions, milkOptions: $milkOptions, sweetenerOptions: $sweetenerOptions, shouldClearData: $shouldClearData, selectedImageIndex: $selectedImageIndex)'; +} + + +} + +/// @nodoc +abstract mixin class _$KioskHomeStateCopyWith<$Res> implements $KioskHomeStateCopyWith<$Res> { + factory _$KioskHomeStateCopyWith(_KioskHomeState value, $Res Function(_KioskHomeState) _then) = __$KioskHomeStateCopyWithImpl; +@override @useResult +$Res call({ + KioskWizardStep currentStep, LatteOrder? order, LatteOrderMetadata? metadata, ModerationEvent? happyPlaceModerationEvent, bool isSubmitting, bool isReverting, DateTime? imageGenerationStartTime, bool awaitingQuestions, LatteImageBatch? imagesBatch, List questions,@LatteOptionConverter() List? milkOptions,@LatteOptionConverter() List? sweetenerOptions, bool shouldClearData, int? selectedImageIndex +}); + + +@override $LatteOrderCopyWith<$Res>? get order;@override $LatteOrderMetadataCopyWith<$Res>? get metadata;@override $LatteImageBatchCopyWith<$Res>? get imagesBatch; + +} +/// @nodoc +class __$KioskHomeStateCopyWithImpl<$Res> + implements _$KioskHomeStateCopyWith<$Res> { + __$KioskHomeStateCopyWithImpl(this._self, this._then); + + final _KioskHomeState _self; + final $Res Function(_KioskHomeState) _then; + +/// Create a copy of KioskHomeState +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? currentStep = null,Object? order = freezed,Object? metadata = freezed,Object? happyPlaceModerationEvent = freezed,Object? isSubmitting = null,Object? isReverting = null,Object? imageGenerationStartTime = freezed,Object? awaitingQuestions = null,Object? imagesBatch = freezed,Object? questions = null,Object? milkOptions = freezed,Object? sweetenerOptions = freezed,Object? shouldClearData = null,Object? selectedImageIndex = freezed,}) { + return _then(_KioskHomeState( +currentStep: null == currentStep ? _self.currentStep : currentStep // ignore: cast_nullable_to_non_nullable +as KioskWizardStep,order: freezed == order ? _self.order : order // ignore: cast_nullable_to_non_nullable +as LatteOrder?,metadata: freezed == metadata ? _self.metadata : metadata // ignore: cast_nullable_to_non_nullable +as LatteOrderMetadata?,happyPlaceModerationEvent: freezed == happyPlaceModerationEvent ? _self.happyPlaceModerationEvent : happyPlaceModerationEvent // ignore: cast_nullable_to_non_nullable +as ModerationEvent?,isSubmitting: null == isSubmitting ? _self.isSubmitting : isSubmitting // ignore: cast_nullable_to_non_nullable +as bool,isReverting: null == isReverting ? _self.isReverting : isReverting // ignore: cast_nullable_to_non_nullable +as bool,imageGenerationStartTime: freezed == imageGenerationStartTime ? _self.imageGenerationStartTime : imageGenerationStartTime // ignore: cast_nullable_to_non_nullable +as DateTime?,awaitingQuestions: null == awaitingQuestions ? _self.awaitingQuestions : awaitingQuestions // ignore: cast_nullable_to_non_nullable +as bool,imagesBatch: freezed == imagesBatch ? _self.imagesBatch : imagesBatch // ignore: cast_nullable_to_non_nullable +as LatteImageBatch?,questions: null == questions ? _self._questions : questions // ignore: cast_nullable_to_non_nullable +as List,milkOptions: freezed == milkOptions ? _self._milkOptions : milkOptions // ignore: cast_nullable_to_non_nullable +as List?,sweetenerOptions: freezed == sweetenerOptions ? _self._sweetenerOptions : sweetenerOptions // ignore: cast_nullable_to_non_nullable +as List?,shouldClearData: null == shouldClearData ? _self.shouldClearData : shouldClearData // ignore: cast_nullable_to_non_nullable +as bool,selectedImageIndex: freezed == selectedImageIndex ? _self.selectedImageIndex : selectedImageIndex // ignore: cast_nullable_to_non_nullable +as int?, + )); +} + +/// Create a copy of KioskHomeState +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$LatteOrderCopyWith<$Res>? get order { + if (_self.order == null) { + return null; + } + + return $LatteOrderCopyWith<$Res>(_self.order!, (value) { + return _then(_self.copyWith(order: value)); + }); +}/// Create a copy of KioskHomeState +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$LatteOrderMetadataCopyWith<$Res>? get metadata { + if (_self.metadata == null) { + return null; + } + + return $LatteOrderMetadataCopyWith<$Res>(_self.metadata!, (value) { + return _then(_self.copyWith(metadata: value)); + }); +}/// Create a copy of KioskHomeState +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$LatteImageBatchCopyWith<$Res>? get imagesBatch { + if (_self.imagesBatch == null) { + return null; + } + + return $LatteImageBatchCopyWith<$Res>(_self.imagesBatch!, (value) { + return _then(_self.copyWith(imagesBatch: value)); + }); +} +} + +// dart format on diff --git a/genlatte/genlatte-ui/lib/src/screens/kiosk/home/kiosk_home_view.dart b/genlatte/genlatte-ui/lib/src/screens/kiosk/home/kiosk_home_view.dart new file mode 100644 index 0000000..d2e1f54 --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/screens/kiosk/home/kiosk_home_view.dart @@ -0,0 +1,445 @@ +// Copyright 2014 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:math'; + +import 'package:firebase_analytics/firebase_analytics.dart'; +import 'package:firebase_auth/firebase_auth.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:genlatte/src/screens/app/app.dart'; +import 'package:genlatte/src/screens/kiosk/home/kiosk_home.dart'; +import 'package:genlatte/src/screens/kiosk/home/steps/steps.dart'; +import 'package:genlatte/src/screens/kiosk/widgets/widgets.dart'; +import 'package:genlatte/src/widgets/widgets.dart'; +import 'package:genlatte_data/models.dart'; +import 'package:get_it/get_it.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +/// {@template KioskHomeScreen} +/// Initial Kiosk home screen. +/// {@endtemplate} +class KioskHomeScreen extends StatefulWidget { + /// {@macro KioskHomeScreen} + const KioskHomeScreen({ + super.key, + this.bloc, + }); + + /// Optional bloc, primarily used for testing. + final KioskHomeBloc? bloc; + + @override + State createState() => _KioskHomeScreenState(); +} + +class _KioskHomeScreenState extends State { + late final KioskHomeBloc bloc = widget.bloc ?? KioskHomeBloc(); + + final _shownHappyPlaceModerationIds = {}; + + @override + Widget build(BuildContext context) { + return LayoutProvider.builder( + builder: (context, info) { + return BlocListener( + bloc: bloc, + listener: (context, state) async { + if (state.happyPlaceModerationEvent != null) { + // Short-circuit if we've already seen this moderation event. + if (_shownHappyPlaceModerationIds.contains( + state.happyPlaceModerationEvent!.id, + )) { + return; + } + + _shownHappyPlaceModerationIds.add( + state.happyPlaceModerationEvent!.id, + ); + bloc.add(const HappyPlaceModerationReasonShown()); + final message = state.happyPlaceModerationEvent!.reason + .capitalize(); + showToast( + context: context, + builder: (BuildContext context, ToastOverlay overlay) { + return SurfaceCard( + child: Basic( + title: const Text( + 'Happy Place rejected', + style: TextStyle(color: AppColors.googleIntroRed), + ).large, + subtitle: Text( + message, + style: const TextStyle(color: AppColors.black), + ).base, + trailing: OutlineButton( + // size: ButtonSize.small, + onPressed: overlay.close, + child: const Text( + 'OK', + style: TextStyle(color: AppColors.googleIntroRed), + ), + ), + trailingAlignment: Alignment.bottomCenter, + ), + ); + }, + location: ToastLocation.bottomCenter, + showDuration: const Duration(seconds: 10), + // onClosed: () {}, + ); + } + }, + child: BlocBuilder( + bloc: bloc, + builder: (context, state) { + return SafeArea( + child: Stack( + children: [ + Positioned.fill( + child: GenLatteScaffold( + headers: [ + AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + switchInCurve: Curves.easeOut, + switchOutCurve: Curves.easeOut, + child: + // TODO(filiph): Use a less drastic measure + // to reclaim vertical space + // when keyboard is shown + (MediaQuery.viewInsetsOf(context).bottom == 0) + ? Padding( + padding: const EdgeInsets.only( + top: 48, + left: 12, + right: 12, + ), + child: Row( + mainAxisAlignment: .spaceBetween, + children: [ + const Spacer(), + Column( + children: [ + TripleTapDetector( + semanticLabel: 'GenLatte', + semanticHint: + 'Triple tap to reset ' + 'the GenLatte ' + 'ordering process', + onPressed: () async { + bloc.add(const StartOver()); + await GetIt.I + .get() + .logEvent( + name: 'kiosk_start_over', + ); + }, + child: const Text('GenLatte').h4, + ), + if (!state + .currentStep + .isFirstStep) ...[ + const SizedBox(height: 8), + SegmentedProgress( + currentStep: state + .currentStep + .progressIndicatorIndex, + totalSteps: 4, + ), + ], + if (state + .currentStep + .isFirstStep) ...[ + // 20 + 8, for the above sized box + // and the above active height + // of 20 + const SizedBox(height: 28), + ], + ], + ), + const Spacer(), + ], + ), + ) + : const SizedBox.shrink(), + ), + ], + footers: [ + if (MediaQuery.viewInsetsOf(context).bottom == 0) + Padding( + padding: const EdgeInsets.symmetric(vertical: 48), + child: TripleTapDetector( + semanticLabel: 'Footer', + semanticHint: 'Triple tap to log out', + onPressed: () => + GetIt.I().signOut(), + child: Footer( + uiScale: max(1, info.width / 1080), + ), + ), + ), + ], + child: Padding( + padding: info.orientation.isPortrait + ? EdgeInsets.zero + : EdgeInsets.symmetric( + horizontal: (info.width * 0.05).clamp( + 12, + 24, + ), + ), + child: Center( + child: ZipPageView( + currentIndex: state.currentStep.stepIndex, + builder: (context, index) { + return buildStep(context, state, info, index); + }, + itemCount: KioskWizardStep.values.length, + onNewPage: (int newPageIndex) { + bloc.add( + KioskHomeEvent.onNewPage( + KioskWizardStep.fromIndex(newPageIndex), + ), + ); + }, + key: const ValueKey('zip-page-view'), + ), + ), + ), + ), + ), + if (!state.currentStep.isFirstStep && + !state.currentStep.isLastStep) + Positioned( + left: 32, + top: 32, + child: GenLatteFilledButton( + onPressed: () async { + bloc.add(const KioskHomeEvent.goBack()); + await GetIt.I.get().logEvent( + name: 'kiosk_go_back', + parameters: {'from': state.currentStep.name}, + ); + }, + label: info.orientation.isPortrait ? null : 'Back', + icon: Icons.arrow_back, + ), + ), + if (state.currentStep != .submitOrder && + state.currentStep != .confirmation && + state.metadata?.imageUrl != null) + Positioned( + right: 32, + top: 32, + child: GenLatteFilledButton( + onPressed: () => bloc.add( + const KioskHomeEvent.goToStep(.submitOrder), + ), + label: info.orientation.isPortrait ? null : 'Submit', + trailingIcon: Icons.arrow_forward, + ), + ), + ], + ), + ); + }, + ), + ); + }, + ); + } + + Widget buildStep( + BuildContext context, + KioskHomeState state, + LayoutInformation info, + int pageIndex, + ) { + final child = switch (KioskWizardStep.fromIndex(pageIndex)) { + .intro => KioskIntro( + key: const ValueKey('step-intro'), + advance: () { + bloc.add(const KioskHomeEvent.goToStep(.name)); + }, + ), + .name => KioskDrinkOrderName( + key: const Key('step-name'), + advance: !state.isSubmitting + ? (String name) async { + bloc.add(SubmitUserName(name)); + await GetIt.I.get().logEvent( + name: 'kiosk_submit_name', + ); + } + : null, + name: state.order?.name, + ), + .milkAndSweetener => KioskDrinkMilkSweetener( + key: const Key('step-milk-sweetener'), + advance: + !state.isSubmitting && + state.order?.milk != null && + state.order?.sweetener != null + ? () async { + bloc.add(const SubmitMilkAndSweetener()); + await GetIt.I.get().logEvent( + name: 'kiosk_submit_milk_sweetener', + parameters: { + 'milk': state.order?.milk ?? 'missing-milk-wtf', + 'sweetener': + state.order?.sweetener ?? 'missing-sweetener-wtf', + }, + ); + } + : null, + milkOptions: state.milkOptions, + selectedMilk: state.order?.milk, + selectedSweetener: state.order?.sweetener, + selectMilk: (milk) => bloc.add(SelectMilk(milk)), + selectSweetener: (sweetener) => bloc.add(SelectSweetener(sweetener)), + sweetenerOptions: state.sweetenerOptions, + username: state.order!.name!, + ), + .happyPlace => KioskHappyPlace( + key: const Key('step-happy-place'), + happyPlace: state.order!.happyPlace, + submitHappyPlace: !state.isSubmitting + ? (String happyPlace) async { + bloc.add(SubmitHappyPlace(happyPlace)); + await GetIt.I.get().logEvent( + name: 'kiosk_submit_happy_place', + ); + } + : null, + ), + .chooseAnImage => KioskChooseAnImage( + key: const Key('step-choose-image'), + imageGenerationStartTime: state.imageGenerationStartTime, + advanceToAcceptOrder: () async { + bloc.add(const AcceptImage()); + await GetIt.I.get().logEvent( + name: 'kiosk_accept_image', + ); + }, + advanceToTweakImage: () async { + bloc.add(const KioskHomeEvent.goToStep(.tweakImage)); + await GetIt.I.get().logEvent( + name: 'kiosk_tweak_image', + ); + }, + isSubmitting: state.isSubmitting, + imagesBatch: state.imagesBatch, + selectedIndex: state.selectedImageIndex, + onSelectImage: (int index) async { + bloc.add(KioskHomeEvent.selectImage(index)); + await GetIt.I.get().logEvent( + name: 'kiosk_select_image', + ); + }, + ), + .tweakImage => KioskTweakImage( + key: const Key('step-tweak-image'), + advance: + !state.isSubmitting && state.questions.every((q) => q.isAnswered) + ? () async { + bloc.add(const KioskHomeEvent.generateRevisedImages()); + await GetIt.I.get().logEvent( + name: 'kiosk_generate_revised_images', + ); + } + : null, + questions: state.questions.take(4).toList(), + onAnswer: (Question question, Object? answer) { + bloc.add(AnswerQuestion(question, answer)); + }, + ), + .chooseATweakedImage => KioskChooseAnImage( + key: const Key('step-choose-tweaked'), + imageGenerationStartTime: state.imageGenerationStartTime, + advanceToAcceptOrder: () async { + bloc.add(const AcceptImage()); + await GetIt.I.get().logEvent( + name: 'kiosk_accept_tweaked_image', + ); + }, + advanceToTweakImage: null, + imagesBatch: state.imagesBatch, + isSubmitting: state.isSubmitting, + selectedIndex: state.selectedImageIndex, + onSelectImage: (int index) async { + bloc.add(KioskHomeEvent.selectImage(index)); + await GetIt.I.get().logEvent( + name: 'kiosk_select_image', + ); + }, + ), + .submitOrder => KioskSubmitOrder( + key: const Key('step-submit-order'), + advance: !state.isSubmitting + ? () async { + bloc.add(const SubmitOrder()); + await GetIt.I.get().logEvent( + name: 'kiosk_submit_order', + ); + } + : null, + metadata: state.metadata!, + order: state.order!, + returnToLatteConfig: () async { + bloc.add(const KioskHomeEvent.goToStep(.milkAndSweetener)); + await GetIt.I.get().logEvent( + name: 'kiosk_return_to_latte_config', + ); + }, + ), + .confirmation => KioskConfirmation( + key: const Key('step-confirmation'), + onNewOrder: () => bloc.add(const StartOver()), + orderNumber: state.metadata!.orderNumber!, + ), + }; + + final Widget? header = switch (KioskWizardStep.fromIndex(pageIndex)) { + .intro || + .name || + .milkAndSweetener || + .happyPlace || + .tweakImage || + .submitOrder || + .confirmation => null, + .chooseAnImage => const KioskChooseAnImageHeader(), + .chooseATweakedImage => + state.imagesBatch != null + ? KioskChooseATweakedImageHeader( + isReverting: state.isReverting, + onRevert: !state.isSubmitting + ? () async { + bloc.add(const RejectImageBatch()); + await GetIt.I.get().logEvent( + name: 'kiosk_reject_image_batch', + ); + } + : null, + ) + : null, + }; + return KioskStep( + header: header, + child: child, + ); + } + + @override + void dispose() { + super.dispose(); + bloc.close().ignore(); + } +} + +extension on String { + /// Capitalizes the first letter of the string. + String capitalize() { + if (isEmpty) return this; + return '${this[0].toUpperCase()}${substring(1)}'; + } +} diff --git a/genlatte/genlatte-ui/lib/src/screens/kiosk/home/steps/CONTEXT.md b/genlatte/genlatte-ui/lib/src/screens/kiosk/home/steps/CONTEXT.md new file mode 100644 index 0000000..9055145 --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/screens/kiosk/home/steps/CONTEXT.md @@ -0,0 +1,42 @@ +# Kiosk Wizard Steps + +**Purpose:** +Contains the constituent UI components for each individual step in the Kiosk ordering wizard. These components are orchestrated by `KioskHomeBloc` and stitched together visually by `ZipPageView`. + +**Detailed File Overviews:** + +- `kiosk_choose_an_image.dart`: + - **Description**: Displays the 4 initial image options generated by Nanobanana based on the user's happy place. + - **Core Logic**: Adjusts layout based on aspect ratio (`_LatteArtCarousel` vs `_LatteArtField`). Highlights selected images dynamically and offers options to either "Accept" a chosen image directly or "Tweak" it. + +- `kiosk_tweak_image.dart`: + - **Description**: The interface where the user answers AI-generated follow-up questions to iterate on a chosen image. + - **Core Logic**: Maps a dynamic list of `Question` records from the server to `ConfigurationCard` widgets using `Flavor` (to give different colors). Changes layout based on aspect ratio/count of questions. + +- `kiosk_happy_place.dart`: + - **Description**: A free-text input step where the user submits their happy place. + - **Core Logic**: Evaluates the local `TextEditingController` state to unblock the "Next" (Submit) button, and triggers `submitHappyPlace`. Beautifully styles the background with gradients and specific flutter-latte assets. + +- `kiosk_submit_order.dart`: + - **Description**: The final confirmation screen. + - **Core Logic**: Displays a recap of the entire order (Milk, Sweetener, Drink Type, Happy Place, and selected AI Image). Upon advancing, the full order is finalized. + +- `kiosk_drink_order_name.dart`: + - **Description**: A simple input step for the customer's name. + +- `kiosk_drink_milk_sweetener.dart`: + - **Description**: A two-question configuration screen for Milk (e.g. Whole, Skim, Oat) and Sweetener preferences using standard `ConfigurationCard` instances. + +- `kiosk_intro.dart`: + - **Description**: The landing state / starting point of the kiosk, introducing the application. + +- `kiosk_step.dart`: + - **Description**: Contains enum definition `KioskWizardStep` declaring the exact linear order of these screens. (May be imported from models or blocs, depending on specific mapping). + +- `steps.dart`: + - **Description**: Barrel export file exposing all steps. + +**Dependencies/Relationships:** +- Strictly consumed by `kiosk/home/kiosk_home_view.dart`. +- They all implement localized layout strategies based on screen aspect ratios (horizontal vs vertical layouts for tablet vs kiosk mode). +- They rely heavily on `kiosk/widgets/zip_item.dart` to integrate with the parent `ZipPageView` animations. diff --git a/genlatte/genlatte-ui/lib/src/screens/kiosk/home/steps/kiosk_choose_an_image.dart b/genlatte/genlatte-ui/lib/src/screens/kiosk/home/steps/kiosk_choose_an_image.dart new file mode 100644 index 0000000..c5dfb22 --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/screens/kiosk/home/steps/kiosk_choose_an_image.dart @@ -0,0 +1,699 @@ +// Copyright 2014 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:math'; + +import 'package:genlatte/src/screens/app/app.dart' show AppColors; +import 'package:genlatte/src/screens/kiosk/widgets/loading_progress.dart'; +import 'package:genlatte/src/screens/kiosk/widgets/widgets.dart'; +import 'package:genlatte/src/screens/kiosk/widgets/zip_item.dart'; +import 'package:genlatte/src/widgets/widgets.dart'; +import 'package:genlatte_data/models.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:text_responsive/text_responsive.dart'; + +/// Header for the kiosk choose an image step. +class KioskChooseAnImageHeader extends StatelessWidget { + /// Instantiates a [KioskChooseAnImageHeader]. + const KioskChooseAnImageHeader({super.key}); + + @override + Widget build(BuildContext context) { + return const Padding( + padding: EdgeInsets.only(top: 8), + child: ParagraphTextWidget( + 'Which one of these is closest\nto what you have in mind?', + style: TextStyle(color: AppColors.white, fontSize: 20), + textAlign: .center, + ), + ); + } +} + +/// Revert button to sit above tweaked images. +class KioskChooseATweakedImageHeader extends StatelessWidget { + /// Instantiates a [KioskChooseATweakedImageHeader]. + const KioskChooseATweakedImageHeader({ + required this.isReverting, + required this.onRevert, + super.key, + }); + + /// Whether the user is reverting to their original images. + final bool isReverting; + + /// Callback to return a user to their original images. + final VoidCallback? onRevert; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(top: 8), + child: Center( + child: GenLatteOutlinedButton.light( + onPressed: onRevert, + label: !isReverting ? 'Revert to originals' : 'Reverting...', + ), + ), + ); + } +} + +const _emptyImages = [null, null, null, null]; + +/// The step in the kiosk wizard where the user chooses an image from the pool +/// of 4 generated by Nanobanana. +class KioskChooseAnImage extends StatelessWidget { + /// Instantiates a [KioskChooseAnImage]. + const KioskChooseAnImage({ + required this.advanceToAcceptOrder, + required this.advanceToTweakImage, + required this.imagesBatch, + required this.isSubmitting, + required this.onSelectImage, + required this.selectedIndex, + required this.imageGenerationStartTime, + super.key, + }); + + /// Callback to invoke when the user skips to the accept order step. + final VoidCallback advanceToAcceptOrder; + + /// Callback to invoke when the user chooses an image. + final VoidCallback? advanceToTweakImage; + + /// Callback to invoke when the user selects an image. + final void Function(int) onSelectImage; + + /// Whether the user is submitting the order. + final bool isSubmitting; + + /// User-selected latte art index. + final int? selectedIndex; + + /// The images to display. + final LatteImageBatch? imagesBatch; + + /// The time when image generation process started. + /// + /// A note on nullability: When choosing an image, + /// the [imageGenerationStartTime] really shouldn't be null. + /// But because of shenanigans around hot restart versus loading state + /// from previous session, it's safer to assume nullable time + /// and deal with it below in the widget tree. + final DateTime? imageGenerationStartTime; + + @override + Widget build(BuildContext context) { + return LayoutProvider.builder( + builder: (context, info) { + return switch (info.aspectRatio) { + < 1.6 => _LatteArtCarousel( + advanceToTweakImage: advanceToTweakImage, + advanceToAcceptOrder: advanceToAcceptOrder, + imagesBatch: imagesBatch, + layoutInfo: info, + isSubmitting: isSubmitting, + onSelectImage: onSelectImage, + selectedIndex: selectedIndex, + imageGenerationStartTime: imageGenerationStartTime, + ), + _ => _LatteArtField( + advanceToAcceptOrder: advanceToAcceptOrder, + advanceToTweakImage: advanceToTweakImage, + imagesBatch: imagesBatch, + layoutInfo: info, + isSubmitting: isSubmitting, + onSelectImage: onSelectImage, + selectedIndex: selectedIndex, + imageGenerationStartTime: imageGenerationStartTime, + ), + }; + }, + ); + } +} + +class _LatteArtCarousel extends StatelessWidget { + const _LatteArtCarousel({ + required this.advanceToAcceptOrder, + required this.advanceToTweakImage, + required this.imagesBatch, + required this.isSubmitting, + required this.layoutInfo, + required this.onSelectImage, + required this.selectedIndex, + required this.imageGenerationStartTime, + }); + + final VoidCallback advanceToAcceptOrder; + final VoidCallback? advanceToTweakImage; + + final LatteImageBatch? imagesBatch; + final LayoutInformation layoutInfo; + final bool isSubmitting; + final void Function(int) onSelectImage; + final int? selectedIndex; + final DateTime? imageGenerationStartTime; + + @override + Widget build(BuildContext context) { + final images = imagesBatch?.images.toList() ?? _emptyImages; + final realImagesCount = images.whereNotNull().length; + + final shouldShowButtons = realImagesCount > 0; + + return LayoutProvider.builder( + builder: (context, info) { + return Stack( + clipBehavior: .none, + fit: .expand, + children: [ + Positioned( + top: 0, + left: 0, + width: 0, + height: 0, + child: SizedBox.shrink( + // This is here for ease of testing (while also preventing + // a forced unmount of the whole widget tree). + key: ValueKey('step-choose-image-${imagesBatch?.id ?? 'none'}'), + ), + ), + Positioned.fill( + child: Column( + children: [ + const Spacer(), + Expanded( + flex: 9, + child: Center( + child: SneakPeakCarousel( + children: [ + ...images.indexed.map( + (imageAndIndex) { + if (images[imageAndIndex.$1] == null) { + return Padding( + padding: info.aspectRatio > 1 + ? EdgeInsets.symmetric( + vertical: info.height * 0.03, + ) + : EdgeInsets.zero, + child: EmptyLatteArt( + key: ValueKey(imageAndIndex.$1.toString()), + ), + ); + } + return LayoutBuilder( + builder: (context, constraints) => ZipItem( + index: 1, + child: Column( + mainAxisAlignment: .center, + children: [ + Center( + child: SizedBox( + width: min( + constraints.maxWidth * 0.8, + constraints.maxHeight * 0.8, + ), + child: Padding( + padding: const EdgeInsets.all(4), + child: _LatteArtImage( + image: imageAndIndex.$2!, + uiScale: 1, + isSelected: + selectedIndex == + imageAndIndex.$1, + onTap: () { + onSelectImage(imageAndIndex.$1); + }, + ), + ), + ), + ), + const SizedBox(height: 12), + ParagraphTextWidget( + imageAndIndex.$2!.description, + style: const TextStyle( + color: AppColors.white, + fontSize: 16, + ), + textAlign: .center, + maxLines: 3, + ), + ], + ), + ), + ); + }, + ), + ], + ), + ), + ), + Expanded( + flex: 3, + child: ZipItem( + index: 0, + child: Padding( + padding: const EdgeInsets.only(top: 16), + child: !shouldShowButtons + ? const SizedBox.shrink() + : Row( + mainAxisAlignment: .center, + children: [ + const Spacer(), + if (advanceToTweakImage != null) ...[ + Expanded( + flex: 3, + child: ResponsiveChevronButton( + onPressed: + (selectedIndex != null && + !isSubmitting) + ? advanceToAcceptOrder + : null, + color: Colors.transparent, + borderColor: AppColors.chevronYellow, + textColor: AppColors.chevronYellow, + style: .flat, + text: 'Accept', + ), + ), + Expanded( + flex: 3, + child: ResponsiveChevronButton( + onPressed: + (selectedIndex != null && + !isSubmitting) + ? advanceToTweakImage + : null, + style: .flat, + text: 'Tweak', + ), + ), + ], + + if (advanceToTweakImage == null) + Expanded( + flex: 5, + child: ResponsiveChevronButton( + onPressed: + (selectedIndex != null && + !isSubmitting) + ? advanceToAcceptOrder + : null, + style: .flat, + text: 'Accept', + ), + ), + const Spacer(), + ], + ), + ), + ), + ), + ], + ), + ), + LoadingProgress( + stillWaiting: realImagesCount == 0, + progressStartTime: imageGenerationStartTime, + padding: EdgeInsets.symmetric( + horizontal: info.width * 0.05, + vertical: info.height * 0.15, + ), + ), + ], + ); + }, + ); + } +} + +class _LatteArtField extends StatelessWidget { + const _LatteArtField({ + required this.advanceToAcceptOrder, + required this.advanceToTweakImage, + required this.imagesBatch, + required this.isSubmitting, + required this.layoutInfo, + required this.onSelectImage, + required this.selectedIndex, + required this.imageGenerationStartTime, + }); + + /// Callback to invoke when the user skips to the accept order step. + final VoidCallback? advanceToAcceptOrder; + + /// Callback to advance the wizard to the tweak image step. + final VoidCallback? advanceToTweakImage; + + /// The images to display. + final LatteImageBatch? imagesBatch; + + /// Whether the UI is submitting. + final bool isSubmitting; + + /// The layout information for the current screen. + final LayoutInformation layoutInfo; + + /// Callback for when the user selects a new image. + final void Function(int) onSelectImage; + + /// Selected image. + final int? selectedIndex; + + /// The time of start of image generation. + final DateTime? imageGenerationStartTime; + + static const double _defaultWidth = 1024; + static const double _defaultHeight = 768; + + @override + Widget build(BuildContext context) { + final images = imagesBatch?.images.toList() ?? _emptyImages; + final realImagesCount = images.whereNotNull().length; + final shouldShowButtons = realImagesCount > 0; + return Row( + children: [ + const Spacer(), + Expanded( + flex: 9, + child: LayoutProvider.builder( + builder: (context, info) => + _buildLatteArtImages(context, info, images), + ), + ), + Expanded( + flex: 2, + child: ZipItem( + index: 0, + child: !shouldShowButtons + ? const SizedBox.shrink() + : Column( + mainAxisAlignment: .center, + children: [ + if (advanceToTweakImage != null) ...[ + ResponsiveChevronButton( + onPressed: (selectedIndex != null && !isSubmitting) + ? advanceToTweakImage + : null, + style: .flat, + text: 'Tweak', + ), + const SizedBox(height: 16), + ResponsiveChevronButton( + onPressed: (selectedIndex != null && !isSubmitting) + ? advanceToAcceptOrder + : null, + color: Colors.transparent, + borderColor: AppColors.chevronYellow, + textColor: AppColors.chevronYellow, + style: .flat, + text: 'Accept', + ), + ], + if (advanceToTweakImage == null) + ResponsiveChevronButton( + onPressed: (selectedIndex != null && !isSubmitting) + ? advanceToAcceptOrder + : null, + style: .vertical, + scale: 0.6, + text: 'Accept', + ), + ], + ), + ), + ), + ], + ); + } + + Widget _buildLatteArtImages( + BuildContext context, + LayoutInformation info, + List images, + ) { + final realImagesCount = images.whereNotNull().length; + + final scale = info.unconstrainedScale( + const Size(_defaultWidth, _defaultHeight), + ); + + // Get the default latte art image positions + final latteArtPositions = _latteArtRowPositions(info, scale); + + // Now shift things around to accentuate the selected image + if (selectedIndex != null) { + _accentuateActiveImageInRow(latteArtPositions, scale); + } + + return Stack( + clipBehavior: .none, + fit: .expand, + children: [ + Positioned( + top: 0, + left: 0, + width: 0, + height: 0, + child: SizedBox.shrink( + // This is here for ease of testing (while also preventing + // a forced unmount of the whole widget tree). + key: ValueKey('step-choose-image-${imagesBatch?.id ?? 'none'}'), + ), + ), + ...latteArtPositions.indexed.map( + (positionAndIndex) { + final image = images[positionAndIndex.$1]; + final child = image == null + ? const EmptyLatteArt() + : ZipItem( + index: latteArtPositions.length - positionAndIndex.$1, + child: _LatteArtImage( + image: image, + uiScale: scale, + onTap: () { + onSelectImage(positionAndIndex.$1); + }, + isSelected: selectedIndex == positionAndIndex.$1, + ), + ); + return AnimatedPositioned( + duration: const Duration(seconds: 2), + curve: Curves.elasticOut, + top: positionAndIndex.$2.center.dy - positionAndIndex.$2.size / 2, + left: + positionAndIndex.$2.center.dx - positionAndIndex.$2.size / 2, + width: positionAndIndex.$2.size, + height: positionAndIndex.$2.size, + key: image != null + ? ValueKey(images[positionAndIndex.$1]!.imageUrl) + : null, + child: child, + ); + }, + ), + ...latteArtPositions.indexed.map( + (positionAndIndex) { + if (images[positionAndIndex.$1] == null) { + return null; + } + return Positioned( + top: + positionAndIndex.$2.center.dy + + positionAndIndex.$2.size / 2 + // bottom of the image + 12, // vertical margin + left: max( + positionAndIndex.$2.center.dx - positionAndIndex.$2.size / 2, + 0, + ), + width: positionAndIndex.$2.size, + height: 100 * scale, + key: ValueKey( + '${images[positionAndIndex.$1]}--' + '${images[positionAndIndex.$1]!.description}', + ), + child: ZipItem( + index: latteArtPositions.length - positionAndIndex.$1, + child: ParagraphTextWidget( + images[positionAndIndex.$1]!.description, + style: TextStyle( + color: AppColors.white, + fontSize: 28 * scale, + ), + textAlign: .center, + maxLines: positionAndIndex.$1 == selectedIndex ? 3 : 4, + ), + ), + ); + }, + ).whereNotNull(), + LoadingProgress( + stillWaiting: realImagesCount == 0, + progressStartTime: imageGenerationStartTime, + padding: EdgeInsets.symmetric( + horizontal: info.width * 0.05, + vertical: info.height * 0.05, + ), + ), + ], + ); + } + + List<_LatteArtPosition> _latteArtRowPositions( + LayoutInformation info, + double scale, + ) { + return [ + _LatteArtPosition( + center: Offset( + info.constraints.maxWidth * 0.12, + info.constraints.maxHeight * 0.35, + ), + size: 150 * scale, + ), + _LatteArtPosition( + center: Offset( + info.constraints.maxWidth * 0.35, + info.constraints.maxHeight * 0.5, + ), + size: 150 * scale, + ), + _LatteArtPosition( + center: Offset( + info.constraints.maxWidth * 0.58, + info.constraints.maxHeight * 0.4, + ), + size: 150 * scale, + ), + _LatteArtPosition( + center: Offset( + info.constraints.maxWidth * 0.81, + info.constraints.maxHeight * 0.45, + ), + size: 150 * scale, + ), + ]; + } + + /// Performs in-place modifications to [latteArtPositions] to make the + /// selected image larger and the other images shift away from the selected + /// image, all assuming the row layout. + void _accentuateActiveImageInRow( + List<_LatteArtPosition> latteArtPositions, + double scale, + ) { + latteArtPositions[selectedIndex!] = latteArtPositions[selectedIndex!].scale( + 1.5, + ); + + int delta = 1; + while (delta < latteArtPositions.length) { + final lowerIndexToModify = selectedIndex! - delta; + if (lowerIndexToModify >= 0) { + latteArtPositions[lowerIndexToModify] = + latteArtPositions[lowerIndexToModify].shiftAwayFrom( + latteArtPositions[selectedIndex!].center, + scale, + ); + } + + final upperIndexToModify = selectedIndex! + delta; + if (upperIndexToModify < latteArtPositions.length) { + latteArtPositions[upperIndexToModify] = + latteArtPositions[upperIndexToModify].shiftAwayFrom( + latteArtPositions[selectedIndex!].center, + scale, + ); + } + delta++; + } + } +} + +class _LatteArtImage extends StatelessWidget { + const _LatteArtImage({ + required this.image, + required this.isSelected, + required this.onTap, + required this.uiScale, + }); + + /// The image to display. + final LatteImage image; + + /// Whether the image is selected. + final bool isSelected; + + /// Callback to invoke when the user taps the image. + final VoidCallback onTap; + + /// The color of the halo. + static const Color haloColor = AppColors.googleBlue; + + /// The scale of the UI. + final double uiScale; + + @override + Widget build(BuildContext context) { + Widget child = LatteImageWidget(imageUrl: image.imageUrl); + + if (isSelected) { + child = Container( + decoration: BoxDecoration( + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: haloColor.withValues( + alpha: 0.8, + ), // Adjust opacity for desired intensity + blurRadius: 8 * uiScale, // How soft the edge of the glow is + spreadRadius: 8 * uiScale, // How much the glow expands outwards + // Default offset of Offset.zero centers the glow around the image + ), + ], + ), + child: child, + ); + } else { + child = GestureDetector(onTap: onTap, child: child); + } + + return child; + } +} + +class _LatteArtPosition { + const _LatteArtPosition({ + required this.center, + required this.size, + }); + + final Offset center; + final double size; + + _LatteArtPosition scale(double scale) { + return _LatteArtPosition( + center: center, + size: size * scale, + ); + } + + _LatteArtPosition shiftAwayFrom(Offset otherCenter, double uiScale) { + return _LatteArtPosition( + center: Offset( + center.dx + (center.dx - otherCenter.dx) * 0.05 * uiScale, + center.dy + (center.dy - otherCenter.dy) * 0.05 * uiScale, + ), + size: size, + ); + } +} + +extension _FilterableList on Iterable { + Iterable whereNotNull() { + return where((T? element) => element != null).cast(); + } +} diff --git a/genlatte/genlatte-ui/lib/src/screens/kiosk/home/steps/kiosk_confirmation.dart b/genlatte/genlatte-ui/lib/src/screens/kiosk/home/steps/kiosk_confirmation.dart new file mode 100644 index 0000000..ba637ae --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/screens/kiosk/home/steps/kiosk_confirmation.dart @@ -0,0 +1,112 @@ +// Copyright 2014 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:math'; +import 'package:genlatte/src/screens/app/app.dart'; +import 'package:genlatte/src/screens/kiosk/widgets/zip_item.dart'; +import 'package:genlatte/src/widgets/widgets.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +/// The final screen in the kiosk wizard, where the user confirms their order. +class KioskConfirmation extends StatelessWidget { + /// Creates a [KioskConfirmation] widget. + const KioskConfirmation({ + required this.onNewOrder, + required this.orderNumber, + super.key, + }); + + /// Callback for when the user starts over. + final VoidCallback onNewOrder; + + /// The order number. + final int orderNumber; + + static const _breakpointWidth = 650; + + @override + Widget build(BuildContext context) { + return LayoutProvider.builder( + builder: (context, info) { + final newline = info.width > _breakpointWidth ? '\n' : ' '; + + final scale = (min(info.width, _breakpointWidth) / _breakpointWidth) + .clamp(0.5, 1.0); + + final whiteTextTitle = TextStyle( + color: AppColors.white, + letterSpacing: 1.5, + fontWeight: FontWeight.w500, + fontSize: 44 * scale, + ); + final orderNumberStyle = whiteTextTitle.copyWith( + fontWeight: FontWeight.w600, + fontSize: 48 * scale, + ); + final whiteTextBody = whiteTextTitle.copyWith( + fontSize: 20 * scale, + ); + final whiteTextSmall = whiteTextTitle.copyWith( + fontSize: 14 * scale, + ); + + return Padding( + padding: EdgeInsets.symmetric(horizontal: info.width * 0.1), + child: Column( + children: [ + const Spacer(), + ZipItem( + index: 4, + child: Text( + 'Order #$orderNumber', + style: orderNumberStyle, + ), + ), + const SizedBox(height: 24), + ZipItem( + index: 3, + child: Text( + 'Pick up your order', + style: whiteTextTitle, + textAlign: .center, + ), + ), + const SizedBox(height: 24), + ZipItem( + index: 2, + child: Text( + 'Proceed to the line to pickup your order.$newline' + 'You can check your place in line on the order board.', + style: whiteTextBody, + textAlign: .center, + ), + ), + const SizedBox(height: 24), + ZipItem( + index: 1, + child: Text( + 'Want to know more?$newline' + 'Talk to someone by the pickup area about using Flutter or ' + 'Firebase.', + style: whiteTextSmall, + textAlign: .center, + ), + ), + const SizedBox(height: 36), + ZipItem( + index: 0, + child: GenLatteOutlinedButton.light( + label: 'Start a new order', + size: .large, + onPressed: onNewOrder, + ), + ), + const Spacer(), + ], + ), + ); + }, + ); + } +} diff --git a/genlatte/genlatte-ui/lib/src/screens/kiosk/home/steps/kiosk_drink_milk_sweetener.dart b/genlatte/genlatte-ui/lib/src/screens/kiosk/home/steps/kiosk_drink_milk_sweetener.dart new file mode 100644 index 0000000..83835f0 --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/screens/kiosk/home/steps/kiosk_drink_milk_sweetener.dart @@ -0,0 +1,230 @@ +// Copyright 2014 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:genlatte/src/screens/kiosk/widgets/zip_item.dart'; +import 'package:genlatte/src/widgets/widgets.dart'; +import 'package:genlatte_data/models.dart'; + +/// Milk / sweetener configuration screen. +class KioskDrinkMilkSweetener extends StatefulWidget { + /// Creates a new [KioskDrinkMilkSweetener]. + const KioskDrinkMilkSweetener({ + required this.advance, + required this.milkOptions, + required this.sweetenerOptions, + required this.selectedMilk, + required this.selectedSweetener, + required this.selectMilk, + required this.selectSweetener, + required this.username, + super.key, + }); + + /// Callback to advance to the next step of the wizard. + final VoidCallback? advance; + + /// The available milk options. + final List? milkOptions; + + /// The available sweetener options. + final List? sweetenerOptions; + + /// The selected milk. + final String? selectedMilk; + + /// The selected sweetener. + final String? selectedSweetener; + + /// Callback to select milk. + final void Function(String) selectMilk; + + /// Callback to select sweetener. + final void Function(String) selectSweetener; + + /// The name the user entered on the previous screen. + final String username; + + @override + State createState() => + _KioskDrinkMilkSweetenerState(); +} + +class _KioskDrinkMilkSweetenerState extends State { + int? _forcedIndex; + + @override + Widget build(BuildContext context) { + return LayoutProvider.builder( + builder: (context, layoutInfo) { + return layoutInfo.aspectRatio > 1.6 + ? _buildRow(context) + : _buildColumn(context); + }, + ); + } + + Widget _buildRow(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: SizedBox( + height: CardShape.tall.height, + child: Align( + child: Row( + mainAxisAlignment: .center, + children: [ + const Spacer(flex: 3), + Expanded( + flex: 4, + child: _MilkChoiceCard( + milkOptions: widget.milkOptions, + selectedMilk: widget.selectedMilk, + selectMilk: widget.selectMilk, + username: widget.username, + ), + ), + const Spacer(), + Expanded( + flex: 4, + child: _SweetenerChoiceCard( + sweetenerOptions: widget.sweetenerOptions, + selectedSweetener: widget.selectedSweetener, + selectSweetener: widget.selectSweetener, + ), + ), + Expanded( + flex: 3, + child: ZipItem( + index: 0, + child: ResponsiveChevronButton( + onPressed: widget.advance, + style: .vertical, + scale: 0.6, + text: 'Next', + ), + ), + ), + ], + ), + ), + ), + ); + } + + Widget _buildColumn(BuildContext context) => Column( + children: [ + const Spacer(), + Expanded( + flex: 9, + child: SneakPeakCarousel( + previewPercentage: 0.2, + activeIndex: _forcedIndex, + onIndexChanged: (index) { + _forcedIndex = index; + }, + children: [ + _MilkChoiceCard( + milkOptions: widget.milkOptions, + selectedMilk: widget.selectedMilk, + selectMilk: widget.selectMilk, + username: widget.username, + ), + _SweetenerChoiceCard( + sweetenerOptions: widget.sweetenerOptions, + selectedSweetener: widget.selectedSweetener, + selectSweetener: widget.selectSweetener, + ), + ], + ), + ), + Expanded( + flex: 3, + child: ZipItem( + index: 0, + child: Padding( + padding: const EdgeInsets.only(top: 16), + child: ResponsiveChevronButton( + onPressed: + widget.selectedMilk != null && + widget.selectedSweetener == null + ? () { + setState(() { + _forcedIndex = 1; + }); + } + : widget.advance, + style: .flat, + text: 'Next', + ), + ), + ), + ), + ], + ); +} + +class _MilkChoiceCard extends StatelessWidget { + const _MilkChoiceCard({ + required this.milkOptions, + required this.selectedMilk, + required this.selectMilk, + required this.username, + }); + + final List? milkOptions; + final String? selectedMilk; + final void Function(String) selectMilk; + final String username; + + @override + Widget build(BuildContext context) { + return ZipItem( + index: 2, + child: ConfigurationCard.buttons( + flavor: Flavor.wood, + onSelected: selectMilk, + question: MultipleChoiceQuestion( + id: 'milk', + body: + 'Hey $username, what kind of milk would you like in your ' + 'latte?', + acceptableAnswers: + milkOptions?.map((option) => option.name).toList() ?? const [], + selectedValue: selectedMilk, + ), + ), + ); + } +} + +class _SweetenerChoiceCard extends StatelessWidget { + const _SweetenerChoiceCard({ + required this.sweetenerOptions, + required this.selectedSweetener, + required this.selectSweetener, + }); + + final List? sweetenerOptions; + final String? selectedSweetener; + final void Function(String) selectSweetener; + + @override + Widget build(BuildContext context) { + return ZipItem( + index: 1, + child: ConfigurationCard.buttons( + flavor: .seaFoam, + onSelected: selectSweetener, + question: MultipleChoiceQuestion( + id: 'sweetener', + body: 'What kind of sweetener would you like?', + acceptableAnswers: + sweetenerOptions?.map((option) => option.name).toList() ?? + const [], + selectedValue: selectedSweetener, + ), + ), + ); + } +} diff --git a/genlatte/genlatte-ui/lib/src/screens/kiosk/home/steps/kiosk_drink_order_name.dart b/genlatte/genlatte-ui/lib/src/screens/kiosk/home/steps/kiosk_drink_order_name.dart new file mode 100644 index 0000000..0767833 --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/screens/kiosk/home/steps/kiosk_drink_order_name.dart @@ -0,0 +1,106 @@ +// Copyright 2014 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:genlatte/src/screens/kiosk/widgets/zip_item.dart'; +import 'package:genlatte/src/widgets/widgets.dart'; +import 'package:genlatte_data/models.dart' show TextQuestion; + +/// First screen of the kiosk flow which asks for the user's name. +class KioskDrinkOrderName extends StatefulWidget { + /// Creates a new [KioskDrinkOrderName]. + const KioskDrinkOrderName({ + required this.advance, + required this.name, + super.key, + }); + + /// The user's name. + final String? name; + + /// Callback to advance to the next step of the wizard. + final void Function(String)? advance; + + @override + State createState() => _KioskDrinkOrderNameState(); +} + +class _KioskDrinkOrderNameState extends State { + late String _name; + + @override + void initState() { + super.initState(); + _name = widget.name ?? ''; + } + + void setName(String name) { + setState(() { + _name = name; + }); + } + + @override + Widget build(BuildContext context) { + return LayoutProvider.builder( + builder: (context, layoutInfo) { + final Axis axis = layoutInfo.aspectRatio < 1.6 + ? .vertical + : .horizontal; + return Padding( + padding: axis == .vertical + ? const EdgeInsets.symmetric(horizontal: 32) + : EdgeInsets.zero, + child: Flex( + direction: axis, + mainAxisAlignment: .spaceEvenly, + children: [ + Spacer(flex: axis == .horizontal ? 3 : 1), + Expanded( + flex: 9, + child: ZipItem( + index: 1, + child: ResponsiveSizedBox( + aspectRatioClamp: (null, 745 / 400, null), + maxSize: const Size(745, 400), + child: ConfigurationCard.text( + flavor: Flavor.wood, + onSelected: setName, + question: TextQuestion( + id: 'name', + body: 'Can I get a first name for your drink order?', + answer: _name, + helpText: 'Your name', + ), + ), + ), + ), + ), + Expanded( + flex: 3, + child: ZipItem( + index: 0, + child: Padding( + padding: axis == .vertical + ? const EdgeInsets.only(top: 16) + : EdgeInsets.zero, + child: ResponsiveChevronButton( + onPressed: widget.advance != null && _name.isNotEmpty + ? () { + widget.advance!.call(_name); + } + : null, + scale: axis == .horizontal ? 0.6 : null, + text: 'Next', + ), + ), + ), + ), + ], + ), + ); + }, + ); + } +} diff --git a/genlatte/genlatte-ui/lib/src/screens/kiosk/home/steps/kiosk_happy_place.dart b/genlatte/genlatte-ui/lib/src/screens/kiosk/home/steps/kiosk_happy_place.dart new file mode 100644 index 0000000..7874d63 --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/screens/kiosk/home/steps/kiosk_happy_place.dart @@ -0,0 +1,410 @@ +// Copyright 2014 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:math'; + +import 'package:flutter/services.dart'; +import 'package:genlatte/src/screens/app/app.dart'; +import 'package:genlatte/src/screens/kiosk/widgets/zip_item.dart'; +import 'package:genlatte/src/widgets/widgets.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:text_responsive/text_responsive.dart'; + +/// {@template KioskHappyPlace} +/// The screen where the user is asked for their happy place. +/// {@endtemplate} +class KioskHappyPlace extends StatefulWidget { + /// {@macro KioskHappyPlace} + const KioskHappyPlace({ + required this.happyPlace, + required this.submitHappyPlace, + super.key, + }); + + /// The user's happy place. + final String? happyPlace; + + /// Callback to submit the user's happy place. + final void Function(String)? submitHappyPlace; + + @override + State createState() => _KioskHappyPlaceState(); +} + +class _KioskHappyPlaceState extends State { + final TextEditingController _controller = TextEditingController(); + + // Used to track when the happy place changes from falsey to truthy to + // activate the button + String? _happyPlace; + + @override + void initState() { + super.initState(); + _controller.text = widget.happyPlace ?? ''; + _controller.addListener(_updateHappyPlaceLocally); + _happyPlace = widget.happyPlace; + } + + @override + void didUpdateWidget(covariant KioskHappyPlace oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.happyPlace == null && oldWidget.happyPlace != null) { + setState(() { + _controller.clear(); + _happyPlace = null; + }); + } + } + + DateTime? _lastToastTime; + + void _showLimitReachedToast() { + final now = DateTime.now(); + if (_lastToastTime == null || + now.difference(_lastToastTime!) > const Duration(seconds: 3)) { + _lastToastTime = now; + showToast( + context: context, + builder: (context, overlay) { + return SurfaceCard( + child: Basic( + title: const Text( + 'Character limit reached', + style: TextStyle(color: AppColors.googleIntroRed), + ).large, + subtitle: const Text( + 'The happy place must be 50 characters or less', + style: TextStyle(color: AppColors.black), + ).base, + trailing: OutlineButton( + onPressed: overlay.close, + child: const Text( + 'OK', + style: TextStyle(color: AppColors.googleIntroRed), + ), + ), + ), + ); + }, + location: ToastLocation.bottomCenter, + showDuration: const Duration(seconds: 3), + ); + } + } + + void _updateHappyPlaceLocally() { + final String? oldHappyPlace = _happyPlace; + _happyPlace = _controller.text; + if (oldHappyPlace == null || + oldHappyPlace.isEmpty && _happyPlace!.isNotEmpty) { + // Trigger an update to activate the button. + setState(() {}); + } + } + + @override + Widget build(BuildContext context) { + return LayoutProvider.builder( + builder: (context, layoutInfo) { + final Axis axis = layoutInfo.aspectRatio < 1.6 + ? .vertical + : .horizontal; + return Padding( + padding: axis == .vertical + ? const EdgeInsets.symmetric(horizontal: 24) + : EdgeInsets.zero, + child: Flex( + direction: axis, + children: [ + if (axis == .horizontal) const Spacer(flex: 3), + if (axis == .vertical) const Spacer(), + Expanded( + flex: 9, + child: ZipItem( + index: 1, + child: ResponsiveSizedBox( + aspectRatioClamp: ( + CardShape.tall.aspectRatio, + null, + CardShape.expanded.aspectRatio, + ), + maxSize: CardShape.expanded.size * 1.2, + child: _HappyPlaceConfigurationCard( + controller: _controller, + onLimitReached: _showLimitReachedToast, + ), + ), + ), + ), + Expanded( + flex: 3, + child: ZipItem( + index: 0, + child: Padding( + padding: axis == .vertical + ? const EdgeInsets.only(top: 16) + : EdgeInsets.zero, + child: ResponsiveChevronButton( + onPressed: + widget.submitHappyPlace != null && + _happyPlace != null && + _happyPlace!.isNotEmpty + ? () => widget.submitHappyPlace!(_happyPlace!) + : null, + scale: axis == .horizontal ? 0.6 : null, + text: 'Next', + ), + ), + ), + ), + ], + ), + ); + }, + ); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } +} + +class _HappyPlaceConfigurationCard extends StatelessWidget { + const _HappyPlaceConfigurationCard({ + required this.controller, + required this.onLimitReached, + }); + + final TextEditingController controller; + final VoidCallback onLimitReached; + + @override + Widget build(BuildContext context) { + return LayoutProvider.builder( + builder: (context, layout) { + // 1.3 chosen by eyeballing when the quadrants start to feel crowded + return layout.aspectRatio < 1.3 + ? _buildColumn(context, layout) + : _buildQuadrants(context, layout); + }, + ); + } + + Widget _buildColumn(BuildContext context, LayoutInformation layout) { + final double edgeInsets = + (layout.constrainingDimension * 0.1) // + .clamp(16, 64); + final stackConstraints = BoxConstraints.tight( + Size( + layout.width - (edgeInsets * 2), + layout.height - (edgeInsets * 2), + ), + ); + + final double floor = layout.constrainingDimension > 250 ? 1 : 0.5; + final double inputScalar = max(floor, layout.constrainingDimension / 400); + final scaledTextStyle = Theme.of(context).typography.large.copyWith( + fontSize: Theme.of(context).typography.large.fontSize! * inputScalar, + ); + return Container( + decoration: BoxDecoration( + color: Flavor.weirdGreen.backgroundColor, + borderRadius: BorderRadius.circular(16), + ), + padding: EdgeInsets.all(edgeInsets), + child: Stack( + children: [ + Positioned( + left: 0, + right: 0, + top: 0, + height: stackConstraints.maxHeight * 0.30, + child: ParagraphTextWidget( + 'Ok, now tell me about your happy place', + style: Theme.of(context).typography.h1, + maxLines: 3, + ), + ), + Positioned( + left: 0, + right: 0, + height: stackConstraints.maxHeight * 0.65, + bottom: 0, + child: ShaderMask( + shaderCallback: (rect) { + return const LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Colors.black, Colors.transparent], + stops: [0.6, 1], + ).createShader(Rect.fromLTRB(0, 0, rect.width, rect.height)); + }, + blendMode: BlendMode.dstIn, + child: Image.asset('assets/flutter-latte.png'), + ), + ), + Positioned( + left: 0, + bottom: 0, + width: stackConstraints.maxWidth, + height: stackConstraints.maxHeight * 0.4, + child: Column( + crossAxisAlignment: .start, + mainAxisAlignment: .end, + children: [ + TextField( + autocorrect: false, + controller: controller, + decoration: BoxDecoration( + border: const Border(), // defaults to BorderStyle.none + borderRadius: BorderRadius.all( + Radius.circular(8 * inputScalar), + ), + color: Theme.of(context).colorScheme.input, + ), + inputFormatters: [ + CharacterLimitFormatter(50, onLimitReached), + ], + maxLength: 50, + placeholder: Text( + 'Imagine your happy place', + overflow: .ellipsis, + style: scaledTextStyle, + ), + style: scaledTextStyle, + ), + const SizedBox(height: 12), + if (layout.constrainingDimension > 250) + const ParagraphTextWidget('This will inspire your latte!'), + ], + ), + ), + ], + ), + ); + } + + Widget _buildQuadrants(BuildContext context, LayoutInformation layout) { + final double edgeInsets = + (layout.constrainingDimension * 0.12) // + .clamp(16, 64); + final stackConstraints = BoxConstraints.tight( + Size( + layout.width - (edgeInsets * 2), + layout.height - (edgeInsets * 2), + ), + ); + + final double floor = layout.constrainingDimension > 250 ? 1 : 0.5; + final double inputScalar = max(floor, layout.constrainingDimension / 400); + final textStyle = Theme.of(context).typography.large; + final scaledTextStyle = textStyle.copyWith( + fontSize: textStyle.fontSize! * inputScalar, + ); + final scaledSmallTextStyle = Theme.of(context).typography.small.copyWith( + fontSize: Theme.of(context).typography.small.fontSize! * inputScalar, + ); + return Container( + decoration: BoxDecoration( + color: Flavor.weirdGreen.backgroundColor, + borderRadius: BorderRadius.circular(16), + ), + padding: EdgeInsets.all(edgeInsets), + child: Stack( + children: [ + Positioned( + right: 0, + top: 0, + bottom: 0, + width: stackConstraints.maxWidth * 0.4, + child: Image.asset('assets/flutter-latte.png'), + ), + Positioned( + left: 0, + top: 0, + width: stackConstraints.maxWidth * 0.55, + height: stackConstraints.maxHeight * 0.55, + child: ParagraphTextWidget( + 'Ok, now tell me about your happy place', + style: Theme.of(context).typography.h1, + maxLines: 3, + ), + ), + Positioned( + left: 0, + bottom: 0, + width: stackConstraints.maxWidth * 0.6, + height: stackConstraints.maxHeight * 0.4, + child: Column( + crossAxisAlignment: .start, + mainAxisAlignment: .end, + children: [ + TextField( + autocorrect: false, + controller: controller, + decoration: BoxDecoration( + border: const Border(), // defaults to BorderStyle.none + borderRadius: BorderRadius.all( + Radius.circular(8 * inputScalar), + ), + color: Theme.of(context).colorScheme.input, + ), + inputFormatters: [ + CharacterLimitFormatter(50, onLimitReached), + ], + maxLength: 50, + placeholder: Text( + 'Imagine your happy place', + style: scaledTextStyle, + ), + style: scaledTextStyle, + ), + const SizedBox(height: 12), + if (layout.constrainingDimension > 250) + Text( + 'This will inspire your latte!', + style: scaledSmallTextStyle, + ), + ], + ), + ), + ], + ), + ); + } +} + +/// A [TextInputFormatter] that limits the number of characters in a +/// [TextField]. +class CharacterLimitFormatter extends TextInputFormatter { + /// Creates a new [CharacterLimitFormatter]. + CharacterLimitFormatter(this.limit, this.onLimitReached); + + /// The maximum number of characters allowed. + final int limit; + + /// Callback to be invoked when the character limit is reached. + final VoidCallback onLimitReached; + + @override + TextEditingValue formatEditUpdate( + TextEditingValue oldValue, + TextEditingValue newValue, + ) { + if (newValue.text.length > limit) { + onLimitReached(); + if (oldValue.text.length >= limit) { + return oldValue; + } + return TextEditingValue( + text: newValue.text.substring(0, limit), + selection: TextSelection.collapsed(offset: limit), + ); + } + return newValue; + } +} diff --git a/genlatte/genlatte-ui/lib/src/screens/kiosk/home/steps/kiosk_intro.dart b/genlatte/genlatte-ui/lib/src/screens/kiosk/home/steps/kiosk_intro.dart new file mode 100644 index 0000000..82b9376 --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/screens/kiosk/home/steps/kiosk_intro.dart @@ -0,0 +1,296 @@ +// Copyright 2014 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_bloc/flutter_bloc.dart'; +import 'package:genlatte/src/screens/app/app.dart'; +import 'package:genlatte/src/screens/kiosk/widgets/zip_item.dart'; +import 'package:genlatte/src/widgets/widgets.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:text_responsive/text_responsive.dart'; + +const _whiteThinStyle = TextStyle( + color: AppColors.white, + fontWeight: FontWeight.normal, +); + +const _whiteBoldStyle = TextStyle( + color: AppColors.white, + fontWeight: FontWeight.bold, +); + +/// First screen of the kiosk flow which asks for the user's name. +class KioskIntro extends StatelessWidget { + /// Creates a new [KioskIntro]. + const KioskIntro({required this.advance, super.key}); + + /// Callback to advance to the next step. + final VoidCallback advance; + + @override + Widget build(BuildContext context) { + final layoutInfo = context.watch(); + final intraCardSpacer = layoutInfo.orientation.isLandscape + ? const SizedBox(width: 16) + : const SizedBox(height: 16); + final startButtonSpacer = layoutInfo.orientation.isLandscape + ? const SizedBox(width: 24) + : const SizedBox(height: 24); + return Padding( + padding: layoutInfo.orientation.axis == .vertical + ? const EdgeInsets.symmetric(horizontal: 32) + : EdgeInsets.zero, + child: Flex( + direction: layoutInfo.orientation.axis, + children: [ + const Spacer(), + Expanded( + flex: 10, + child: Flex( + direction: layoutInfo.orientation.axis, + children: [ + const Expanded( + flex: 3, + child: ZipItem( + index: 3, + child: _IntroCard( + color: AppColors.googleIntroRed, + stepIndex: 1, + cta: 'Customize\nyour latte', + ), + ), + ), + intraCardSpacer, + + const Expanded( + flex: 3, + child: ZipItem( + index: 2, + child: _IntroCard( + color: AppColors.googleIntroBlue, + stepIndex: 2, + cta: 'Create your\nlatte art', + ), + ), + ), + intraCardSpacer, + const Expanded( + flex: 3, + child: ZipItem( + index: 1, + child: _IntroCard( + color: AppColors.googleIntroGreen, + stepIndex: 3, + cta: 'Pick up\nyour drink', + helpText: 'AT THE BARISTA STATION', + ), + ), + ), + startButtonSpacer, + ], + ), + ), + Expanded( + flex: 2, + child: ZipItem( + index: 0, + child: ResponsiveChevronButton( + onPressed: advance, + text: 'Start', + ), + ), + ), + const Spacer(), + ], + ), + ); + } +} + +class _IntroCard extends StatelessWidget { + const _IntroCard({ + required this.color, + required this.stepIndex, + required this.cta, + this.helpText, + }); + + final Color color; + + /// Number to show in a circle above the text. + final int stepIndex; + + /// Main text. + final String cta; + + /// Optional text below the [cta]. + final String? helpText; + + /// Standard width when the UI is in landscape mode (and the card itself is + /// vertical); + static const _defaultLandscapeWidth = 200; + + /// Standard height when the UI is in portrait mode (and the card itself is + /// horizontal); + static const _defaultPortraitHeight = 110; + + /// Minimum available width below which we must further shrink font sizes + /// while the UI is in portrait mode (and the card is using a Column) + static const _minPortraitWidth = 350; + + static const _minLandscapeAspectRatio = 0.65; + static const _maxLandscapeAspectRatio = 0.933; + + static const _minPortraitAspectRatio = 1.3; + static const _maxPortraitAspectRatio = 4.0; + + @override + Widget build(BuildContext context) { + final layoutInfo = context.watch(); + return ResponsiveSizedBox.builder( + aspectRatioClamp: + (layoutInfo.orientation.isLandscape + ? ( + _minLandscapeAspectRatio, + null, + _maxLandscapeAspectRatio, + ) + : ( + _minPortraitAspectRatio, + null, + _maxPortraitAspectRatio, + )) + as AcceptableAspectRatios, + builder: (context, size) { + final constraints = BoxConstraints( + maxWidth: size.width, + maxHeight: size.height, + ); + return Container( + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(20), + ), + child: layoutInfo.orientation == .landscape + ? _buildColumn(context, constraints) + : _buildRow(context, constraints), + ); + }, + ); + } + + Widget _buildRow(BuildContext context, BoxConstraints constraints) { + double fontScalar = constraints.maxHeight / _defaultPortraitHeight; + if (constraints.maxWidth < _minPortraitWidth) { + fontScalar = fontScalar.clamp( + constraints.maxWidth / _minPortraitWidth, + 1, + ); + } + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 12), + child: Row( + mainAxisAlignment: .spaceBetween, + children: [ + _StepCircle(stepIndex: stepIndex, fontScalar: fontScalar), + const Spacer(), + Column( + mainAxisAlignment: .center, + children: [ + Text( + cta, + textAlign: TextAlign.center, + style: _whiteThinStyle.copyWith( + fontSize: 24 * fontScalar, + ), + ), + if (helpText != null && helpText!.isNotEmpty) ...[ + const SizedBox(height: 8), + Text( + helpText!, + textAlign: TextAlign.center, + style: _whiteThinStyle.copyWith( + fontSize: 11 * fontScalar, + ), + ), + ], + ], + ), + const Spacer(), + ], + ), + ); + } + + Widget _buildColumn( + BuildContext context, + BoxConstraints constraints, + ) { + double fontScalar = constraints.maxWidth / _defaultLandscapeWidth; + final aspectRatio = constraints.maxWidth / constraints.maxHeight; + + // If the aspect ratio is too flat (too landscape), then a really wide width + // can calculate text sizes that are too large for the available height. + if (aspectRatio > _maxLandscapeAspectRatio) { + fontScalar = fontScalar * (_maxLandscapeAspectRatio / aspectRatio); + } + + double topPaddingScalar = 1; + if (aspectRatio < _minLandscapeAspectRatio) { + topPaddingScalar = _minLandscapeAspectRatio / aspectRatio; + } + + return Column( + children: [ + SizedBox(height: constraints.maxHeight * 0.15 * topPaddingScalar), + _StepCircle(stepIndex: stepIndex, fontScalar: fontScalar), + Padding( + padding: const EdgeInsets.only(top: 16), + child: ParagraphTextWidget( + cta, + textAlign: TextAlign.center, + style: _whiteThinStyle.copyWith( + fontSize: 21 * fontScalar, + ), + ), + ), + if (helpText != null && helpText!.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 16), + child: ParagraphTextWidget( + helpText!, + textAlign: TextAlign.center, + style: _whiteThinStyle.copyWith( + fontSize: 10 * fontScalar, + ), + ), + ), + const SizedBox(height: 32), + ], + ); + } +} + +class _StepCircle extends StatelessWidget { + const _StepCircle({required this.stepIndex, required this.fontScalar}); + + final int stepIndex; + final double fontScalar; + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + border: Border.all(color: AppColors.white), + shape: BoxShape.circle, + ), + padding: EdgeInsets.all(16 * fontScalar), + child: Text( + stepIndex.toString(), + style: _whiteBoldStyle.copyWith( + fontSize: 18 * fontScalar, + ), + ), + ); + } +} diff --git a/genlatte/genlatte-ui/lib/src/screens/kiosk/home/steps/kiosk_step.dart b/genlatte/genlatte-ui/lib/src/screens/kiosk/home/steps/kiosk_step.dart new file mode 100644 index 0000000..6369a59 --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/screens/kiosk/home/steps/kiosk_step.dart @@ -0,0 +1,52 @@ +// Copyright 2014 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:genlatte/src/widgets/widgets.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +/// Generic layout wrapper for Kiosk steps. +class KioskStep extends StatelessWidget { + /// Instantiates a new [KioskStep]. + const KioskStep({ + required this.child, + this.header, + super.key, + }); + + /// Optional header widget to exist below the segmented progress indicator. + final Widget? header; + + /// The content of the step. + final Widget child; + + @override + Widget build(BuildContext context) { + return LayoutProvider.builder( + builder: (context, info) { + final verticalPadding = + info.height * (info.orientation.isLandscape ? 0.07 : 0.035); + return Stack( + children: [ + if (header != null) ...[ + Positioned( + left: 0, + right: 0, + top: 0, + height: info.height * 0.2, + child: header!, + ), + ], + Positioned( + left: 0, + right: 0, + top: verticalPadding, + bottom: verticalPadding, + child: child, + ), + ], + ); + }, + ); + } +} diff --git a/genlatte/genlatte-ui/lib/src/screens/kiosk/home/steps/kiosk_submit_order.dart b/genlatte/genlatte-ui/lib/src/screens/kiosk/home/steps/kiosk_submit_order.dart new file mode 100644 index 0000000..cc1d996 --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/screens/kiosk/home/steps/kiosk_submit_order.dart @@ -0,0 +1,304 @@ +// Copyright 2014 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:math' show min; + +import 'package:flutter_svg/svg.dart'; +import 'package:genlatte/src/screens/kiosk/widgets/zip_item.dart'; +import 'package:genlatte/src/widgets/widgets.dart'; +import 'package:genlatte_data/models.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +/// The final step in the kiosk wizard, where the user accepts their order. +class KioskSubmitOrder extends StatelessWidget { + /// Creates a [KioskSubmitOrder] widget. + const KioskSubmitOrder({ + required this.advance, + required this.metadata, + required this.order, + required this.returnToLatteConfig, + super.key, + }); + + /// Callback for when the user accepts the order. + /// + /// If the value is null then the order is probably being submitted as we + /// speak. + final VoidCallback? advance; + + /// The order to display. + final LatteOrder order; + + /// The order's server-owned metadata. + final LatteOrderMetadata metadata; + + /// Press handler for the "Edit drink" button. + final VoidCallback returnToLatteConfig; + + @override + Widget build(BuildContext context) { + return LayoutProvider.builder( + builder: (context, info) { + return info.aspectRatio < 1.6 + ? _buildColumn(context, info) + : _buildRow(context, info); + }, + ); + } + + Widget _buildColumn(BuildContext context, LayoutInformation info) { + final imageDiameter = min(info.width * 0.5, info.height * 0.3); + final verticalScalar = info.height / 500; + final h3ScaledText = _whiteText.copyWith( + fontSize: 24 * verticalScalar, + ); + final pTagScaledText = _whiteText.copyWith( + fontSize: 14 * verticalScalar, + ); + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: Column( + children: [ + if (metadata.imageUrl != null) + ZipItem( + index: 2, + child: LatteImageWidget( + imageUrl: metadata.imageUrl!, + dimension: imageDiameter, + ), + ), + if (metadata.imageUrl == null) + SizedBox( + height: imageDiameter, + width: imageDiameter, + child: const CircularProgressIndicator(), + ), + SizedBox(height: 12 * verticalScalar), + ZipItem( + index: 2, + child: Text( + 'Order recap for ${order.name}', + textAlign: .center, + style: h3ScaledText, + ), + ), + SizedBox(height: 12 * verticalScalar), + ZipItem( + index: 2, + child: Wrap( + alignment: .center, + spacing: 24, + runSpacing: 12, + children: [ + Row( + mainAxisSize: .min, + children: [ + const GreenCheckMark(), + SizedBox(width: 8 * verticalScalar), + Text('Latte', style: pTagScaledText), + ], + ), + Row( + mainAxisSize: .min, + children: [ + const GreenCheckMark(), + SizedBox(width: 8 * verticalScalar), + Text( + '${order.milk!} milk', + style: pTagScaledText, + ), + ], + ), + Row( + mainAxisSize: .min, + children: [ + const GreenCheckMark(), + SizedBox(width: 8 * verticalScalar), + Text( + order.sweetener!.toLowerCase() != 'none' + ? order.sweetener! + : 'No sweetener', + style: pTagScaledText, + ), + ], + ), + ], + ), + ), + SizedBox(height: 12 * verticalScalar), + ZipItem( + index: 2, + child: GenLatteOutlinedButton.light( + onPressed: returnToLatteConfig, + label: 'Edit drink', + ), + ), + SizedBox(height: 24 * verticalScalar), + ZipItem( + index: 1, + child: Text( + 'Your happy place latte art:', + textAlign: .center, + style: h3ScaledText, + ), + ), + SizedBox(height: 8 * verticalScalar), + ZipItem( + index: 1, + child: Text( + '“${order.happyPlace!.trim()}”', + textAlign: .center, + style: h3ScaledText.copyWith(fontWeight: .bold), + ), + ), + const Spacer(), + ZipItem( + index: 0, + child: ResponsiveChevronButton( + onPressed: advance, + style: .flat, + scale: 0.6 * verticalScalar, + text: 'Submit', + ), + ), + const Spacer(), + ], + ), + ); + } + + Widget _buildRow(BuildContext context, LayoutInformation info) { + final topPadding = info.height * 0.15; + final horizontalPadding = info.width * 0.1; + final availableHorizontalSpace = info.width - (horizontalPadding * 2); + final availableVerticalSpace = info.height - (topPadding * 2); + + final statusColumnWidth = availableHorizontalSpace * 0.3; + final imageColumnWidth = availableHorizontalSpace * 0.4; + final submitButtonWidth = availableHorizontalSpace * 0.3; + + const imagePadding = EdgeInsets.fromLTRB(32, 0, 32, 32); + final imageDiameter = min( + imageColumnWidth - (imagePadding.left + imagePadding.right), + availableVerticalSpace - (imagePadding.top + imagePadding.bottom), + ); + + final milkConfigText = _whiteText.copyWith( + fontSize: 36 * statusColumnWidth / 300, + ); + + /// Number of pixels down from the top of the row until the center of the + /// image + final centerOfImageY = imagePadding.top + imageDiameter / 2; + + return Stack( + children: [ + Positioned( + top: topPadding, + left: horizontalPadding, + width: statusColumnWidth, + height: availableVerticalSpace, + child: ZipItem( + index: 2, + child: Column( + crossAxisAlignment: .start, + children: [ + const Text('Order recap for', style: _whiteText), + Text('${order.name}:', style: milkConfigText), + const Spacer(), + Row( + children: [ + const GreenCheckMark(), + const SizedBox(width: 12), + Text('Latte', style: milkConfigText), + ], + ), + Row( + children: [ + const GreenCheckMark(), + const SizedBox(width: 12), + Text('${order.milk} milk', style: milkConfigText), + ], + ), + Row( + children: [ + const GreenCheckMark(), + const SizedBox(width: 12), + if (order.sweetener!.toLowerCase() == 'none') + Text('No sweetener', style: milkConfigText), + if (order.sweetener!.toLowerCase() != 'none') + Text('${order.sweetener}', style: milkConfigText), + ], + ), + const Spacer(), + GenLatteOutlinedButton.light( + onPressed: returnToLatteConfig, + label: 'Edit drink', + ), + const Spacer(flex: 3), + ], + ), + ), + ), + Positioned( + top: topPadding, + left: horizontalPadding + statusColumnWidth, + width: imageColumnWidth, + height: availableVerticalSpace, + child: ZipItem( + index: 1, + child: Column( + children: [ + Center( + child: LatteImageWidget( + imageUrl: metadata.imageUrl!, + dimension: imageDiameter, + ), + ), + SizedBox(height: imagePadding.bottom), + const Text('Your happy place latte art:', style: _whiteText), + const SizedBox(height: 12), + Text( + '${order.happyPlace}', + textAlign: .center, + style: _whiteText, + ).h3, + ], + ), + ), + ), + Positioned( + top: topPadding, + right: horizontalPadding, + width: submitButtonWidth, + height: centerOfImageY * 2, + child: ZipItem( + index: 0, + child: ResponsiveChevronButton( + onPressed: advance, + style: .flat, + scale: submitButtonWidth / 300, + text: 'Submit', + ), + ), + ), + ], + ); + } +} + +/// A green check mark icon. +class GreenCheckMark extends StatelessWidget { + /// Creates a [GreenCheckMark] widget. + const GreenCheckMark({super.key}); + + @override + Widget build(BuildContext context) { + return SvgPicture.asset('assets/green-check.svg'); + } +} + +const _whiteText = TextStyle( + color: Colors.white, +); diff --git a/genlatte/genlatte-ui/lib/src/screens/kiosk/home/steps/kiosk_tweak_image.dart b/genlatte/genlatte-ui/lib/src/screens/kiosk/home/steps/kiosk_tweak_image.dart new file mode 100644 index 0000000..3a04f15 --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/screens/kiosk/home/steps/kiosk_tweak_image.dart @@ -0,0 +1,308 @@ +// Copyright 2014 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:genlatte/src/screens/kiosk/widgets/zip_item.dart'; +import 'package:genlatte/src/widgets/widgets.dart'; +import 'package:genlatte_data/models.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +// import 'package:web_browser_detect/web_browser_detect.dart'; + +/// Answer questions to tweak a selected image. +class KioskTweakImage extends StatefulWidget { + /// Creates a new [KioskTweakImage]. + KioskTweakImage({ + required this.advance, + required this.questions, + required this.onAnswer, + super.key, + }) : assert( + questions.isEmpty || (questions.length >= 2 && questions.length <= 4), + 'Must provide 0 or 2-4 questions', + ); + + /// Callback to advance to the next step of the wizard. + final VoidCallback? advance; + + /// Callback when a question is answered. + final void Function(Question, Object?) onAnswer; + + /// The questions to answer. + final List questions; + + @override + State createState() => _KioskTweakImageState(); +} + +class _KioskTweakImageState extends State { + // static const double _minHeightForFieldView = 400; + + late final TextEditingController _textController; + late final FocusNode _focusNode; + late final Map _cardKeys; + + @override + void initState() { + super.initState(); + final textQuestion = widget.questions.whereType().firstOrNull; + _textController = TextEditingController(text: textQuestion?.answer); + _focusNode = FocusNode(); + _cardKeys = { + for (final q in widget.questions) q.id: GlobalKey(debugLabel: q.id), + }; + } + + @override + void dispose() { + _textController.dispose(); + _focusNode.dispose(); + super.dispose(); + } + + Widget _buildQuestionCard(int index, Question question) { + return KeyedSubtree( + key: _cardKeys[question.id], + child: ResponsiveSizedBox( + aspectRatioClamp: (0.5, null, 3), + child: ConfigurationCard.forQuestion( + question, + flavor: Flavor.forIndex(index), + onAnswer: (answer) => widget.onAnswer(question, answer), + textEditingController: question is TextQuestion + ? _textController + : null, + focusNode: question is TextQuestion ? _focusNode : null, + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + if (widget.questions.isEmpty) { + return const Center(child: CircularProgressIndicator(size: 48)); + } + + // bool forceCarousel = false; + // if (kIsWeb) { + // final browser = Browser(); + // if (browser.browserAgent == .chrome) { + // forceCarousel = true; + // } + // } + + return LayoutProvider.builder( + builder: _buildCarousel, + // info.aspectRatio < 1.6 || + // info.height < _minHeightForFieldView || + // forceCarousel + // ? _buildCarousel(context, info) + // : _buildField(context, info), + ); + } + + Widget _buildField(BuildContext context, LayoutInformation info) { + return Row( + children: [ + const Spacer(flex: 3), + Expanded( + flex: 9, + child: LayoutProvider.builder( + builder: (context, layoutInfo) { + return Stack( + children: switch (widget.questions.length) { + 2 => _buildTwoQuestions(context, layoutInfo), + 3 => _buildThreeQuestions(context, layoutInfo), + 4 => _buildFourQuestions(context, layoutInfo), + _ => throw UnimplementedError(), + }, + ); + }, + ), + ), + Expanded( + flex: 3, + child: ZipItem( + index: 0, + child: Padding( + padding: const EdgeInsets.only(top: 16), + child: ResponsiveChevronButton( + onPressed: widget.advance, + scale: 0.6, + text: 'Next', + ), + ), + ), + ), + ], + ); + } + + List _buildTwoQuestions( + BuildContext context, + LayoutInformation info, + ) { + return [ + Positioned( + top: 0, + left: 0, + width: info.size.width * 0.46, + height: info.size.height, + key: ValueKey(widget.questions[0].id), + child: ZipItem( + index: 2, + child: _buildQuestionCard(0, widget.questions[0]), + ), + ), + Positioned( + top: 0, + right: 0, + width: info.size.width * 0.46, + height: info.size.height, + key: ValueKey(widget.questions[1].id), + child: ZipItem( + index: 1, + child: _buildQuestionCard(1, widget.questions[1]), + ), + ), + ]; + } + + List _buildThreeQuestions( + BuildContext context, + LayoutInformation layoutInfo, + ) { + return [ + Positioned( + top: 0, + left: 0, + width: layoutInfo.size.width * 0.46, + height: layoutInfo.size.height, + key: ValueKey(widget.questions[0].id), + child: ZipItem( + index: 2, + child: _buildQuestionCard(0, widget.questions[0]), + ), + ), + Positioned( + top: 0, + right: 0, + width: layoutInfo.size.width * 0.46, + height: layoutInfo.size.height * 0.46, + key: ValueKey(widget.questions[1].id), + child: ZipItem( + index: 1, + child: _buildQuestionCard(1, widget.questions[1]), + ), + ), + Positioned( + bottom: 0, + right: 0, + width: layoutInfo.size.width * 0.46, + height: layoutInfo.size.height * 0.46, + key: ValueKey(widget.questions[2].id), + child: ZipItem( + index: 1, + child: _buildQuestionCard(2, widget.questions[2]), + ), + ), + ]; + } + + List _buildFourQuestions( + BuildContext context, + LayoutInformation layoutInfo, + ) { + return [ + Positioned( + top: 0, + left: 0, + width: layoutInfo.size.width * 0.46, + height: layoutInfo.size.height * 0.46, + key: ValueKey(widget.questions[0].id), + child: ZipItem( + index: 2, + child: _buildQuestionCard(0, widget.questions[0]), + ), + ), + Positioned( + bottom: 0, + left: 0, + width: layoutInfo.size.width * 0.46, + height: layoutInfo.size.height * 0.46, + key: ValueKey(widget.questions[1].id), + child: ZipItem( + index: 2, + child: _buildQuestionCard(1, widget.questions[1]), + ), + ), + Positioned( + top: 0, + right: 0, + width: layoutInfo.size.width * 0.46, + height: layoutInfo.size.height * 0.46, + key: ValueKey(widget.questions[2].id), + child: ZipItem( + index: 1, + child: _buildQuestionCard(2, widget.questions[2]), + ), + ), + Positioned( + bottom: 0, + right: 0, + width: layoutInfo.size.width * 0.46, + height: layoutInfo.size.height * 0.46, + key: ValueKey(widget.questions[3].id), + child: ZipItem( + index: 1, + child: _buildQuestionCard(3, widget.questions[3]), + ), + ), + ]; + } + + Widget _buildCarousel(BuildContext context, LayoutInformation info) { + int? activeIndex; + if (_focusNode.hasFocus) { + final textQuestionIndex = widget.questions.indexWhere( + (q) => q is TextQuestion, + ); + if (textQuestionIndex != -1) { + activeIndex = textQuestionIndex; + } + } + + return Column( + children: [ + const Spacer(), + Expanded( + flex: 9, + child: SneakPeakCarousel( + activeIndex: activeIndex, + children: widget.questions.indexed + .map( + (q) => ZipItem( + index: q.$1, + child: _buildQuestionCard(q.$1, q.$2), + ), + ) + .toList(), + ), + ), + Expanded( + flex: 3, + child: ZipItem( + index: 0, + child: Padding( + padding: const EdgeInsets.only(top: 16), + child: ResponsiveChevronButton( + onPressed: widget.advance, + text: 'Next', + ), + ), + ), + ), + ], + ); + } +} diff --git a/genlatte/genlatte-ui/lib/src/screens/kiosk/home/steps/steps.dart b/genlatte/genlatte-ui/lib/src/screens/kiosk/home/steps/steps.dart new file mode 100644 index 0000000..30531b1 --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/screens/kiosk/home/steps/steps.dart @@ -0,0 +1,13 @@ +// Copyright 2014 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 'kiosk_choose_an_image.dart'; +export 'kiosk_confirmation.dart'; +export 'kiosk_drink_milk_sweetener.dart'; +export 'kiosk_drink_order_name.dart'; +export 'kiosk_happy_place.dart'; +export 'kiosk_intro.dart'; +export 'kiosk_step.dart'; +export 'kiosk_submit_order.dart'; +export 'kiosk_tweak_image.dart'; diff --git a/genlatte/genlatte-ui/lib/src/screens/kiosk/kiosk.dart b/genlatte/genlatte-ui/lib/src/screens/kiosk/kiosk.dart new file mode 100644 index 0000000..dafafd1 --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/screens/kiosk/kiosk.dart @@ -0,0 +1,5 @@ +// Copyright 2014 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 'home/kiosk_home.dart'; diff --git a/genlatte/genlatte-ui/lib/src/screens/kiosk/widgets/CONTEXT.md b/genlatte/genlatte-ui/lib/src/screens/kiosk/widgets/CONTEXT.md new file mode 100644 index 0000000..cc2b782 --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/screens/kiosk/widgets/CONTEXT.md @@ -0,0 +1,27 @@ +# Kiosk Widgets + +**Purpose:** +A library of modular, common widgets used to flesh out the user interface and structural behavior of the Kiosk screens. Features page transition tools and progress indicators. + +**Detailed File Overviews:** + +- `empty_latte_art.dart`: + - **Description**: Handles drawing an outline cup/mug, utilized before user-generated imagery is available. + +- `segmented_progress.dart`: + - **Description**: A horizontal progress bar indicating completion steps. + - **Core Logic**: Renders a row of pill shapes, animating the width and color of active/inactive segments to telegraph the user's progress through the Kiosk wizard. + +- `zip_page_view.dart`: + - **Description**: A custom, complex page navigator (`ZipPageView`, `ZipPage`, `ZipPageState`). + - **Core Logic**: Overrides standard `PageView` physics and handles "zipping" items in and out sequentially across wizards. Calculates precise staggering delay offsets (`_staggerDelay`) based on a descending registration of sub-item indices, causing left-most or right-most elements to cascade elegantly via `CurvedAnimation`. + - **Usage/Exports**: This wraps the entirety of the `KioskHomeScreen` wizard workflow. + +- `zip_item.dart`: + - **Description**: A wrapper for individual elements displayed on a `ZipPage`, causing them to bind to the page's transition animations seamlessly. + +- `widgets.dart`: + - **Description**: Barrel export file. + +**Dependencies/Relationships:** +- Utilized entirely by `kiosk/home` and its `steps` subdirectory to render polished onboarding and ordering flows. diff --git a/genlatte/genlatte-ui/lib/src/screens/kiosk/widgets/empty_latte_art.dart b/genlatte/genlatte-ui/lib/src/screens/kiosk/widgets/empty_latte_art.dart new file mode 100644 index 0000000..7757a65 --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/screens/kiosk/widgets/empty_latte_art.dart @@ -0,0 +1,224 @@ +// Copyright 2014 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:math' as math; +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; + +// Class to hold unique data for each swirling arc per instance +class _ArcConfig { + _ArcConfig({ + required this.phaseOffset, + required this.baseRadiusFactor, + required this.speedFactor, + required this.opacityFactor, + required this.maxSweep, + }); + + // Factory constructor for generating a random config for one arc + factory _ArcConfig.random() { + final rand = math.Random(); + return _ArcConfig( + phaseOffset: rand.nextDouble() * math.pi * 2, // Random starting phase + baseRadiusFactor: + 0.3 + rand.nextDouble() * 0.1, // Slight base radius variation + speedFactor: 0.8 + rand.nextDouble() * 0.4, // Slight speed variation + opacityFactor: 0.3 + rand.nextDouble() * 0.1, // Base opacity variation + maxSweep: + math.pi / 1.5 + rand.nextDouble() * (math.pi / 2), // Random max sweep + ); + } + + final double phaseOffset; + final double baseRadiusFactor; + final double speedFactor; + final double opacityFactor; + final double maxSweep; +} + +/// Draws dream-like swirling latte art. +class EmptyLatteArt extends StatefulWidget { + /// Instantiates an [EmptyLatteArt]. + const EmptyLatteArt({super.key}); + + @override + State createState() => _EmptyLatteArtState(); +} + +class _EmptyLatteArtState extends State + with SingleTickerProviderStateMixin { + late Ticker _ticker; + + // ValueNotifier is a highly efficient way to rebuild only the CustomPaint + // without rebuilding the entire widget tree on every frame. + final ValueNotifier _time = ValueNotifier(0); + late List<_ArcConfig> _arcConfigs; + + static const _swirlSpeed = 12; + + @override + void initState() { + super.initState(); + _arcConfigs = List.generate(6, (index) => _ArcConfig.random()); + + // Create a ticker that fires every frame. + // It provides the total elapsed time, which grows infinitely and never + // resets. + _ticker = createTicker((Duration elapsed) { + // Use microseconds for high-precision, perfectly smooth double values + _time.value = elapsed.inMicroseconds / Duration.microsecondsPerSecond; + }); + + _ticker.start().ignore(); + } + + @override + void dispose() { + _ticker.dispose(); + _time.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + final size = math.min(constraints.maxWidth, constraints.maxHeight); + return SizedBox( + width: size, + height: size, + child: ValueListenableBuilder( + valueListenable: _time, + builder: (context, time, child) { + return CustomPaint( + painter: _LattePainter(time / _swirlSpeed, _arcConfigs), + ); + }, + ), + ); + }, + ); + } +} + +class _LattePainter extends CustomPainter { + _LattePainter(this.progress, this.arcConfigs); + final double progress; + final List<_ArcConfig> arcConfigs; + + @override + void paint(Canvas canvas, Size size) { + final center = Offset(size.width / 2, size.height / 2); + final radius = math.min(size.width, size.height) / 2; + + // --- LAYER 1: Large, Blurry Outer Circle (Outline) --- + final outlinePaint = Paint() + ..color = + const Color(0xFF4E342E) // Deep espresso brown + ..style = PaintingStyle.fill + // Increased blur for very soft outline + ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 30); + + // Draw slightly larger than radius to cover edges + canvas.drawCircle(center, radius * 1.05, outlinePaint); + + // --- LAYER 2: Solid Inner Circle --- + final softInnerPaint = Paint() + ..color = + const Color(0xFF4E342E) // Same deep brown + ..style = PaintingStyle.fill + // ADDED: A moderate blur to melt the hard edge into the outer layer + ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 2); + + // Draw inner circle. The radius stays the same, but the edge is now soft. + canvas.drawCircle(center, radius * 0.95, softInnerPaint); + + // --- LAYER 3: Swirling, Morphing Arcs (Inside Solid Circle) --- + // Setup the "foam" paint (reduce arc blur slightly to make them "less + // blurred") + final foamPaint = Paint() + ..style = PaintingStyle.stroke + ..strokeCap = StrokeCap.round + // Maintain some blur for dreamy feel, but less than outer circle blur + ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 6); + + // Draw multiple swirling, morphing arcs using unique instance data + for (int i = 0; i < arcConfigs.length; i++) { + final config = arcConfigs[i]; + + // Use unique phase offset & speed for starting angle, incorporating + // global progress + final startAngle = + (progress * math.pi * 4 * config.speedFactor) + config.phaseOffset; + + // Oscillate sweep, based on unique max sweep and progress/phase + final sweepAngle = + (config.maxSweep / 1.5) + + math.sin(progress * math.pi * 2 + config.phaseOffset) * + (config.maxSweep / 3); + + // Oscillate radius, using unique base radius factor and variation + double currentRadius = + radius * config.baseRadiusFactor + + (i * radius * 0.08) + + math.cos(progress * math.pi * 4 + config.phaseOffset) * 8.0; + + // Ensure arcs stay contained mostly within solid inner circle + // (adjust slightly) + if (currentRadius > radius * 0.93) currentRadius = radius * 0.93; + + // Morphing thickness (less per-instance variation here, but still morphs + // over time) + foamPaint.strokeWidth = + 10.0 + 8.0 * math.cos(progress * math.pi * 2 + config.phaseOffset); + + // Morphing opacity (using unique opacity factor and base phase for + // uniqueness) + final opacity = + config.opacityFactor + + 0.4 * math.sin(progress * math.pi * 2 + config.phaseOffset).abs(); + foamPaint.color = const Color(0xFFD7CCC8).withValues(alpha: opacity); + + // Draw the arc + final rect = Rect.fromCircle(center: center, radius: currentRadius); + canvas.drawArc(rect, startAngle, sweepAngle, false, foamPaint); + } + + // --- Central Swirling Blob (Optional, make slightly unique) --- + // Use data from one arc config to introduce slight per-instance variation + // in center + final centralRandFactor = arcConfigs[0].phaseOffset * 0.1; + final centerFoamPaint = Paint() + ..color = const Color(0xFFEFEBE9).withValues( + alpha: 0.4 + 0.2 * math.sin(progress * math.pi * 2 + centralRandFactor), + ) + ..style = PaintingStyle.fill + ..maskFilter = const MaskFilter.blur( + BlurStyle.normal, + 16, + ); // Maintain blur for center + + canvas.drawOval( + Rect.fromCenter( + center: center, + width: + radius * 0.6 + + math.cos(progress * math.pi * 2 + centralRandFactor) * 10, + height: + radius * 0.4 + + math.sin(progress * math.pi * 2 + centralRandFactor) * 10, + ), + centerFoamPaint, + ); + } + + @override + bool shouldRepaint(covariant _LattePainter oldDelegate) { + // Re-paint when progress changes (animation) or if configs differ (not + // usually applicable per instance, but safer check) + return oldDelegate.progress != progress || + oldDelegate.arcConfigs != arcConfigs; + } +} diff --git a/genlatte/genlatte-ui/lib/src/screens/kiosk/widgets/loading_progress.dart b/genlatte/genlatte-ui/lib/src/screens/kiosk/widgets/loading_progress.dart new file mode 100644 index 0000000..de6e903 --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/screens/kiosk/widgets/loading_progress.dart @@ -0,0 +1,459 @@ +// Copyright 2014 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:math'; + +import 'package:genlatte/src/screens/app/theme.dart'; +import 'package:genlatte/src/widgets/loading_dash.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +/// A widget that only shows itself when [stillWaiting] is true, +/// and fades out otherwise (ignoring pointers). It is therefore safe +/// to have this widget in a [Stack] above other widgets. +/// +/// The widget shows an animation ([LoadingDash]) and a progress bar +/// below it. It is designed to take the user's mind away from the fact +/// they are waiting. +/// +/// Note that the progress bar is a lie. We _don't_ actually know how far +/// in the process we are at any point. All we know is the [expectedDuration] +/// and [worstCaseDuration]. +class LoadingProgress extends StatefulWidget { + /// Creates a new [LoadingProgress]. + const LoadingProgress({ + required this.progressStartTime, + required this.padding, + required this.stillWaiting, + this.expectedDuration = const Duration(seconds: 30), + this.worstCaseDuration = const Duration(minutes: 1, seconds: 20), + this.primaryColor = AppColors.chevronYellow, + this.secondaryColor = AppColors.latteArtGold, + super.key, + }) : assert( + worstCaseDuration > expectedDuration, + 'Worst case duration must be longer than expected duration', + ); + + /// The expected duration of the loading process. + /// + /// Choose a duration at the top of the expected range so that the user + /// is more likely to be pleasantly surprised rather than infuriated. + /// 90% of the users should see the action completed _sooner_ than + /// the given duration. + final Duration expectedDuration; + + /// 99.9% of users should see the action completed before this time. + /// After that, the progress bar no longer moves. + /// + /// This _MUST_ be higher than [expectedDuration]. + final Duration worstCaseDuration; + + /// The time at which this progress bar started. + /// + /// This exists in case the widget needs to go in and out of the widget tree + /// but we don't want to restart the progress each time. + final DateTime? progressStartTime; + + /// This is `true` if the loading progress should be shown. + /// + /// When set to `false`, the widget will immediately stop responding to + /// pointer events, will start fading away, and after that, will + /// remove its children from the widget tree. + final bool stillWaiting; + + /// Padding for the main content of the progress indicator. + final EdgeInsets padding; + + /// The main color of the progress bar. + final Color primaryColor; + + /// Accent color (used for the border around the progress bar). + final Color secondaryColor; + + @override + State createState() => _LoadingProgressState(); +} + +class _LoadingProgressState extends State + with SingleTickerProviderStateMixin { + late final AnimationController opacityController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 300), + reverseDuration: const Duration(milliseconds: 1000), + ); + + late DateTime startTime; + + DateTime _latestTime = DateTime.now(); + + double _progress = 0; + + Timer? _timer; + + /// A 'designed' curve that transforms the real (linear) progress to + /// a non-linear curve that 'vibes' better with the human psyche. + /// See [_recomputeProgressTween]. + late TweenSequence progressTween; + + /// This becomes `true` only when [LoadingProgress.stillWaiting] is `true` + /// and the [opacityController]'s value is non-zero (it is not dismissed). + bool _visible = false; + + @override + Widget build(BuildContext context) { + final progress = widget.stillWaiting + ? progressTween.transform(_progress.clamp(0, 1)) + : 1.0; + + final screenWidth = MediaQuery.widthOf(context); + final tickerScaler = TextScaler.linear(min(1, screenWidth / 600)); + + return Visibility( + // Avoid paying the price for showing the widget subtree below + // (incl. the Dash movie). + visible: _visible, + child: IgnorePointer( + // As soon as we start fading out, ignore pointer. + ignoring: widget.stillWaiting, + child: Padding( + padding: widget.padding, + child: FadeTransition( + opacity: opacityController, + child: Column( + crossAxisAlignment: .stretch, + children: [ + const Expanded(child: LoadingDash()), + const SizedBox(height: 40), + Stack( + children: [ + _ProgressBar( + progress: progress, + primaryColor: widget.primaryColor, + secondaryColor: widget.secondaryColor, + ), + Positioned.fill( + child: Center( + child: WrappedText( + style: (context, theme) => + theme.typography.p.copyWith( + // Make the ticker text blend as a difference + // of the background. + foreground: Paint() + ..color = const Color(0xFFFFFFFF) + ..blendMode = BlendMode.difference, + fontSize: tickerScaler.scale( + theme.typography.p.fontSize!, + ), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: _Ticker( + sinceStart: _latestTime.difference(startTime), + ), + ), + ), + ), + ), + ], + ), + ], + ), + ), + ), + ), + ); + } + + @override + void didUpdateWidget(covariant LoadingProgress oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.progressStartTime != null && + widget.progressStartTime != oldWidget.progressStartTime) { + startTime = widget.progressStartTime!; + } + + if (widget.stillWaiting && !oldWidget.stillWaiting) { + _start(); + unawaited(opacityController.forward()); + _visible = true; + } else if (!widget.stillWaiting && oldWidget.stillWaiting) { + _stop(); + unawaited(opacityController.reverse()); + } + + _recomputeProgressTween(); + } + + @override + void dispose() { + _timer?.cancel(); + opacityController.dispose(); + super.dispose(); + } + + @override + void initState() { + super.initState(); + startTime = widget.progressStartTime ?? DateTime.now(); + _recomputeProgressTween(); + + opacityController.addStatusListener(_onAnimationStatusChange); + + if (widget.stillWaiting) { + _start(); + unawaited(opacityController.forward()); + _visible = true; + } + } + + void _onAnimationStatusChange(AnimationStatus status) { + if (status == .dismissed && !widget.stillWaiting) { + _visible = false; + } + } + + void _recomputeProgressTween() { + progressTween = TweenSequence([ + // Start subtly faster. + TweenSequenceItem( + tween: Tween(begin: 0.01, end: 0.15), + weight: 0.10 * widget.expectedDuration.inMilliseconds, + ), + // Most of the progression is mostly gradual. + TweenSequenceItem( + tween: Tween(begin: 0.15, end: 0.90), + weight: 0.90 * widget.expectedDuration.inMilliseconds, + ), + // Slow down at the very end. Hopefully, most users don't see this. + TweenSequenceItem( + tween: Tween(begin: 0.90, end: 0.98), + weight: + 0.5 * + (widget.worstCaseDuration - widget.expectedDuration).inMilliseconds, + ), + // The very last leg where almost no progress is achieved. + TweenSequenceItem( + tween: Tween(begin: 0.98, end: 0.995), + weight: + 0.5 * + (widget.worstCaseDuration - widget.expectedDuration).inMilliseconds, + ), + ]); + } + + void _start() { + _timer?.cancel(); + _timer = Timer.periodic(const Duration(milliseconds: 500), _tick); + _tick(null); + } + + void _stop() { + _timer?.cancel(); + } + + void _tick(Object? _) { + if (!mounted) return; + _latestTime = DateTime.now(); + final elapsed = _latestTime.difference(startTime); + setState(() { + _progress = + elapsed.inMilliseconds / widget.worstCaseDuration.inMilliseconds; + }); + } +} + +class _ProgressBar extends StatefulWidget { + const _ProgressBar({ + required this.progress, + required this.primaryColor, + required this.secondaryColor, + }); + + final double progress; + + final Color primaryColor; + + final Color secondaryColor; + + @override + State<_ProgressBar> createState() => _ProgressBarState(); +} + +class _ProgressBarState extends State<_ProgressBar> + with SingleTickerProviderStateMixin<_ProgressBar> { + late final animationController = AnimationController( + vsync: this, + ); + + final Duration stepDuration = const Duration(milliseconds: 300); + + final Curve stepCurve = Curves.easeOut; + + @override + Widget build(BuildContext context) { + return SizedBox( + width: double.infinity, + height: 40, + child: DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20), + border: Border.all(color: widget.primaryColor), + ), + child: Padding( + padding: const EdgeInsets.all(4), + child: AnimatedBuilder( + animation: animationController, + builder: (context, child) => FractionallySizedBox( + alignment: Alignment.centerLeft, + widthFactor: animationController.value, + child: child, + ), + child: DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + color: widget.secondaryColor, + ), + ), + ), + ), + ), + ); + } + + @override + void didUpdateWidget(covariant _ProgressBar oldWidget) { + super.didUpdateWidget(oldWidget); + unawaited( + animationController.animateTo( + widget.progress.clamp(0, 1), + duration: stepDuration, + curve: stepCurve, + ), + ); + } + + @override + void dispose() { + animationController.dispose(); + super.dispose(); + } + + @override + void initState() { + super.initState(); + unawaited( + animationController.animateTo( + widget.progress.clamp(0, 1), + duration: stepDuration, + curve: stepCurve, + ), + ); + } +} + +class _Ticker extends StatelessWidget { + const _Ticker({ + required this.sinceStart, + // ignore: unused_element_parameter + this.messages = _defaultMessages, + this.switchDuration = const Duration(seconds: 3), + }) : assert( + switchDuration > Duration.zero, + 'Switch duration must be non-zero', + ); + + static const List _defaultMessages = [ + 'Generating four images in parallel', + 'Expanding prompts via Gemini Flash', + 'Tokenizing input prompts', + 'Calculating Gemini multimodal embeddings', + 'Encoding text prompts into latent space', + 'Peeling initial nanobananas', + 'Sampling from Gaussian noise distribution', + 'Injecting prompt embeddings into the diffusion process', + 'Calibrating nanobanana attention heads', + 'Aligning embeddings with visual features', + 'Executing reverse diffusion steps', + 'Decoding latents into pixel space', + 'Refining high-frequency details', + 'Scaling resolution via variational autoencoder', + 'Embedding SynthID provenance metadata', + 'Converging on final pixel arrangements', + // At this point, most users are away. The following is to give some + // entertainment to the poor souls who need to wait longer. + 'Hallucinating 100% progress', + 'Reticulating splines', + 'Constructing additional pylons', + 'Rerouting power from the holodeck', + 'Trying to exit vim', + 'Reversing the polarity of the neutron flow', + 'Gathering your party before venturing forth', + 'Compensating for Heisenberg compensators', + "Saying: \"I'm sorry, Dave. I'm afraid I can't do that.\"", + 'Downloading more RAM', + 'Establishing a secure connection to Skynet', + 'Dividing by zero (as a treat)', + 'Warming up the flux capacitor', + 'Bypassing the Kobayashi Maru scenario', + 'Executing `sudo rm -rf /`', + 'Calculating the answer to life, the universe, and everything', + 'Resolving circular dependencies', + 'Blowing into the cartridge', + 'Commenting out broken unit tests', + 'Scanning for Cylon raiders', + 'Defragmenting the mainframe', + 'Punching widget trees', + 'Equipping +5 GPU of Rendering', + 'Ensuring the flow of spice', + 'Ignoring the warning signs in the logs', + 'Calculating PageRank', + 'Requisitioning a bigger boat', + 'Executing order 66', + 'Turning the TPUs up to eleven', + 'Going ahead and making my day', + 'Turning it off and on again', + '', + 'Ah shucks, here we go again...', + ]; + + final Duration sinceStart; + + final Duration switchDuration; + + final List messages; + + @override + Widget build(BuildContext context) { + final index = + (sinceStart.inMilliseconds ~/ switchDuration.inMilliseconds) % + messages.length; + final message = messages[index]; + + return ClipRect( + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 500), + switchOutCurve: Curves.ease, + switchInCurve: Curves.ease, + // The default FadeTransition doesn't work well with + // BlendMode.difference so we're sliding the text up and down. + // The ClipRect above makes sure the text disappears from view. + transitionBuilder: (child, animation) => SlideTransition( + position: Tween( + begin: const Offset(0, 1), + end: Offset.zero, + ).animate(animation), + child: child, + ), + child: Text( + message, + // Key must be provided for AnimatedSwitcher to work. + key: ValueKey(message), + maxLines: 1, + overflow: .ellipsis, + ), + ), + ); + } +} diff --git a/genlatte/genlatte-ui/lib/src/screens/kiosk/widgets/segmented_progress.dart b/genlatte/genlatte-ui/lib/src/screens/kiosk/widgets/segmented_progress.dart new file mode 100644 index 0000000..ffa2aa5 --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/screens/kiosk/widgets/segmented_progress.dart @@ -0,0 +1,107 @@ +// Copyright 2014 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_animate/flutter_animate.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +/// A horizontal progress bar that is segmented into multiple steps, with the +/// current step being indicated by increased width. +class SegmentedProgress extends StatelessWidget { + /// Creates a new [SegmentedProgress]. + const SegmentedProgress({ + required this.currentStep, + required this.totalSteps, + this.inactiveHeight = 8.0, + this.activeHeight = 20.0, + this.maxWidth = 220, + super.key, + }) : assert( + currentStep < totalSteps, + 'currentStep must be less than totalSteps; you have an off-by-1 error', + ); + + /// The current step. + final int currentStep; + + /// The total number of steps. + final int totalSteps; + + /// The height of all steps whose index does not equal [currentStep]. + final double inactiveHeight; + + /// The height of the step whose index equals [currentStep]. + final double activeHeight; + + /// The maximum width of the progress bar. + final double maxWidth; + + @override + Widget build(BuildContext context) { + return SizedBox( + width: maxWidth, + height: activeHeight, + child: _SegmentedProgressInner( + currentStep: currentStep, + totalSteps: totalSteps, + inactiveHeight: inactiveHeight, + activeHeight: activeHeight, + ), + ); + } +} + +class _SegmentedProgressInner extends StatelessWidget { + const _SegmentedProgressInner({ + required this.currentStep, + required this.totalSteps, + required this.inactiveHeight, + required this.activeHeight, + }); + + /// The current step. + final int currentStep; + + /// The total number of steps. + final int totalSteps; + + /// The height of all steps whose index does not equal [currentStep]. + final double inactiveHeight; + + /// The height of the step whose index equals [currentStep]. + final double activeHeight; + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + final totalWidth = constraints.maxWidth; + final segmentWidth = (totalWidth * 0.92) / totalSteps; + + return Row( + mainAxisAlignment: .spaceBetween, + children: List.generate(totalSteps, (index) { + final height = index == currentStep ? activeHeight : inactiveHeight; + + return AnimatedContainer( + duration: const Duration(milliseconds: 300), + curve: Curves.easeOut, + width: segmentWidth, + height: height, + decoration: ShapeDecoration( + color: index <= currentStep + ? Theme.of(context).colorScheme.primary + : Theme.of(context).colorScheme.muted, + shape: const StadiumBorder(), + ), + ); + }), + ); + }, + ).animate().fadeIn( + // 100ms per item on the intro card, of which there are 4 + // 100ms being the outro speed of each ZipItem's animation + delay: const Duration(milliseconds: 400), + ); + } +} diff --git a/genlatte/genlatte-ui/lib/src/screens/kiosk/widgets/widgets.dart b/genlatte/genlatte-ui/lib/src/screens/kiosk/widgets/widgets.dart new file mode 100644 index 0000000..928c622 --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/screens/kiosk/widgets/widgets.dart @@ -0,0 +1,7 @@ +// Copyright 2014 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 'empty_latte_art.dart'; +export 'segmented_progress.dart'; +export 'zip_page_view.dart'; diff --git a/genlatte/genlatte-ui/lib/src/screens/kiosk/widgets/zip_item.dart b/genlatte/genlatte-ui/lib/src/screens/kiosk/widgets/zip_item.dart new file mode 100644 index 0000000..7eb32ae --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/screens/kiosk/widgets/zip_item.dart @@ -0,0 +1,73 @@ +// Copyright 2014 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:genlatte/src/screens/kiosk/widgets/widgets.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +/// An item that animates in or out on its own timing based on its [index]. +class ZipItem extends StatefulWidget { + /// Instantiates a new [ZipItem]. + const ZipItem({ + required this.index, + required this.child, + super.key, + }); + + /// Order of this item's animation in or out. + final int index; + + /// Child widget to animate. + final Widget child; + + @override + State createState() => _ZipItemState(); +} + +class _ZipItemState extends State { + Animation? _animation; + ZipPageState? _pageState; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + // Register with parent to get staggered timing + _pageState = ZipPage.of(context); + if (_pageState != null) { + _pageState!.registerItem(widget.index); + _animation = _pageState!.getItemAnimation(widget.index); + } + } + + @override + Widget build(BuildContext context) { + if (_animation == null || _pageState == null) return widget.child; + + return AnimatedBuilder( + animation: _animation!, + builder: (context, child) { + // Value 0.0 = Offscreen + // Value 1.0 = OnScreen + + final screenWidth = MediaQuery.sizeOf(context).width; + + // When value is 0, we want to be at offset (screenWidth * direction) + // When value is 1, we want to be at 0 + + // Invert value for translation calculation + final value = _animation!.value; + final double translation = + (_pageState!.flyDirection * screenWidth) * (1 - value); + + // Add a slight opacity fade for smoothness + final double opacity = value.clamp(0, 1); + + return Transform.translate( + offset: Offset(translation, 0), + child: Opacity(opacity: opacity, child: child), + ); + }, + child: widget.child, + ); + } +} diff --git a/genlatte/genlatte-ui/lib/src/screens/kiosk/widgets/zip_page_view.dart b/genlatte/genlatte-ui/lib/src/screens/kiosk/widgets/zip_page_view.dart new file mode 100644 index 0000000..33e855f --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/screens/kiosk/widgets/zip_page_view.dart @@ -0,0 +1,277 @@ +// Copyright 2014 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. + +/// @docImport 'package:genlatte/src/screens/kiosk/widgets/zip_item.dart'; +library; + +import 'dart:math' as math; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +/// Manages a series of pages with sub-items that animate in and out +/// one item at a time. +/// +/// Individual sub-items are wrapped in a [ZipItem] widget, each of which +/// accepts an integer `index` parameter. These indicies must appear in +/// descending order within the wrapping widget's build method. Behavior is +/// undefined if they are registered in any other order. The reason for this +/// is that the [ZipPage] widget must immediately know the maximum number of +/// children so that the first widget to appear can receive an [Animation] with +/// the correct total duration. +class ZipPageView extends StatefulWidget { + /// Instantiates a new [ZipPageView]. + const ZipPageView({ + required this.builder, + required this.itemCount, + required this.currentIndex, + required this.onNewPage, + super.key, + }); + + /// The pages to display. + final IndexedWidgetBuilder builder; + + /// The number of pages to display. + final int itemCount; + + /// The current index of the page. + final int currentIndex; + + /// Callback for when a new page is about to be displayed. This is not called + /// until after the previous page is gone, meaning it is safe to remove state + /// associated with the previous page or to load data for the new page. + final void Function(int) onNewPage; + + @override + State createState() => _ZipPageViewState(); +} + +class _ZipPageViewState extends State { + late PageController _pageController; + late int _currentIndex; + late int _previousIndex; + + // Updated to either reflect _previousIndex or _currentIndex depending on + // whether we are in the first or second half of the transition animation. + late int _indexToBuild; + + bool _isAnimating = false; + + // By default, all pages animate-in. However, that would cause the first page + // to flicker; so this flag allows the first page to start fully in-place. + bool _hasNavigated = false; + + final Map> _pageKeys = {}; + + @override + void initState() { + super.initState(); + _currentIndex = widget.currentIndex; + _indexToBuild = _currentIndex; + _pageController = PageController(initialPage: _currentIndex); + } + + @override + void didUpdateWidget(covariant ZipPageView oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.currentIndex != oldWidget.currentIndex) { + _previousIndex = _currentIndex; + _currentIndex = widget.currentIndex; + _handleTransition(widget.currentIndex - oldWidget.currentIndex).ignore(); + } + } + + GlobalKey _getKey(int index) { + return _pageKeys.putIfAbsent(index, GlobalKey.new); + } + + Future _handleTransition(int direction) async { + if (_isAnimating) return; + + if (_currentIndex < 0 || _currentIndex >= widget.itemCount) { + return; + } + + setState(() { + _isAnimating = true; + _hasNavigated = true; // Mark that future builds should start hidden + }); + + // 1. Exit current page + final animatingOutPageKey = _getKey(_previousIndex); + if (animatingOutPageKey.currentState != null) { + await animatingOutPageKey.currentState!._animateExit(direction); + _indexToBuild = _currentIndex; + } + + // 2. Move Controller + _pageController.jumpToPage(_currentIndex); + + // 3. Wait for build (imperative vs declarative sync) + // We need the new page to mount in its "hidden" state before we animate + // it in. + await Future.delayed(const Duration(milliseconds: 50)); + + // 4. Enter new page + final nextPageKey = _getKey(_currentIndex); + if (nextPageKey.currentState != null) { + widget.onNewPage(_currentIndex); + await nextPageKey.currentState!._animateEntrance(direction); + } + + setState(() => _isAnimating = false); + } + + @override + Widget build(BuildContext context) { + return PageView.builder( + controller: _pageController, + physics: const NeverScrollableScrollPhysics(), + itemCount: widget.itemCount, + itemBuilder: (context, index) { + // Determine initial visibility + final bool isInitialPage = index == 0 && !_hasNavigated; + + return ZipPage( + key: _getKey(index), + startVisible: isInitialPage, + child: widget.builder(context, _indexToBuild), + ); + }, + ); + } +} + +/// A page that animates in and out one item at a time. +class ZipPage extends StatefulWidget { + /// Instantiates a new [ZipPage]. + const ZipPage({ + required this.child, + super.key, + this.startVisible = true, + }); + + /// The child widget to display. + final Widget child; + + /// Differentiates the initial page, which starts visible, from subsequent + /// pages, which are invisible until animated-in. + final bool startVisible; + + @override + State createState() => ZipPageState(); + + /// Retrieves the [ZipPageState] for the nearest [ZipPage] ancestor. + static ZipPageState? of(BuildContext context) { + return context.findAncestorStateOfType(); + } +} + +/// The state of a [ZipPage]. +class ZipPageState extends State with TickerProviderStateMixin { + late AnimationController _controller; + int _childCount = 0; + + final Duration _itemDuration = const Duration(milliseconds: 400); + final Duration _staggerDelay = const Duration(milliseconds: 100); + + // Default fly direction + double _flyDirection = 1; + + int? _highestIndexRegistered; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + value: widget.startVisible ? 1 : 0, + duration: const Duration(milliseconds: 1000), + ); + } + + /// Allow dynamic updates to duration as items register + void registerItem(int index) { + _childCount++; + _updateDuration(index); + } + + void _updateDuration(int index) { + if (_highestIndexRegistered == null) { + _highestIndexRegistered = index; + } else { + _highestIndexRegistered = math.max(_highestIndexRegistered!, index); + } + final totalMs = + (_highestIndexRegistered! * _staggerDelay.inMilliseconds) + + _itemDuration.inMilliseconds; + _controller.duration = Duration(milliseconds: totalMs); + } + + Future _animateExit(int swipeDirection) async { + _flyDirection = -swipeDirection.toDouble(); + await _controller.reverse(); // Goes from 1.0 to 0.0 + } + + Future _animateEntrance(int swipeDirection) async { + _flyDirection = swipeDirection.toDouble(); + + // Ensure we start at 0 (Hidden) + _controller.value = 0.0; + + // Animate to 1 (Visible) + await _controller.forward(); + } + + /// Retrieves the animation for the given item index. + Animation getItemAnimation(int index) { + if (_childCount == 0) return const AlwaysStoppedAnimation(1); + + final totalMs = _controller.duration!.inMilliseconds; + + // EXITS (Reverse curve) + // The current/original behavior: descending index order -> left-most out first. + final exitStartMs = index * _staggerDelay.inMilliseconds; + final exitEndMs = exitStartMs + _itemDuration.inMilliseconds; + + // ENTRANCES (Forward curve) + // Invert the index so that left-most (highest index) animates in first, + // then next right-most, etc. + // _highestIndexRegistered is guaranteed to be correct here since indices + // must be registered in descending order. + final invertedIndex = (_highestIndexRegistered ?? index) - index; + final entranceStartMs = invertedIndex * _staggerDelay.inMilliseconds; + final entranceEndMs = entranceStartMs + _itemDuration.inMilliseconds; + + return Tween(begin: 0, end: 1).animate( + CurvedAnimation( + parent: _controller, + curve: Interval( + entranceStartMs / totalMs, + math.min(1, entranceEndMs / totalMs), + curve: Curves.easeOutExpo, + ), + reverseCurve: Interval( + exitStartMs / totalMs, + math.min(1, exitEndMs / totalMs), + curve: Curves.easeOutExpo, + ), + ), + ); + } + + /// The direction of the fly animation. + double get flyDirection => _flyDirection; + + @override + Widget build(BuildContext context) { + _childCount = 0; // Reset count for rebuilds + return widget.child; + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } +} diff --git a/genlatte/genlatte-ui/lib/src/screens/login/CONTEXT.md b/genlatte/genlatte-ui/lib/src/screens/login/CONTEXT.md new file mode 100644 index 0000000..182d1f5 --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/screens/login/CONTEXT.md @@ -0,0 +1,20 @@ +# Login Screen + +**Purpose:** +Provides the authentication entry point interface across all user personas. + +**Detailed File Overviews:** + +- `login.dart`: + - **Description**: Barrel export file. + +- `login_view.dart`: + - **Description**: The visual component for signing in. + - **Core Logic**: Presents a stylized animated Google log-in button. Depending on the environment configurations, it may trigger the `FirebaseAuth.instance.signInWithPopup` using Google Auth providers. + +- `login_bloc.dart`: + - **Description**: State management backing the Login screen. + - **Core Logic**: Intercepts `GoogleLoginEvent` triggers, managing the transition between loading spinners and error states. If authentication fails, it yields customized error states back to the view for `shadcn_flutter` toasts. + +**Dependencies/Relationships:** +- Tied to `core/auth/auth_bloc` indirectly (since `AuthBloc` intercepts the actual `User` object generated by Google signing in here and propagates the redirects). diff --git a/genlatte/genlatte-ui/lib/src/screens/login/login.dart b/genlatte/genlatte-ui/lib/src/screens/login/login.dart new file mode 100644 index 0000000..f0d914f --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/screens/login/login.dart @@ -0,0 +1,6 @@ +// Copyright 2014 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 'login_bloc.dart'; +export 'login_view.dart'; diff --git a/genlatte/genlatte-ui/lib/src/screens/login/login_bloc.dart b/genlatte/genlatte-ui/lib/src/screens/login/login_bloc.dart new file mode 100644 index 0000000..3331a79 --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/screens/login/login_bloc.dart @@ -0,0 +1,74 @@ +// Copyright 2014 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_auth/firebase_auth.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:logging/logging.dart'; + +part 'login_bloc.freezed.dart'; + +final _log = Logger('LoginBloc'); + +typedef _Emit = Emitter; + +/// {@template LoginBloc} +/// {@endtemplate} +class LoginBloc extends Bloc { + /// {@macro LoginBloc} + LoginBloc(this._authService) : super(LoginState.initial()) { + on( + (event, _Emit emit) => switch (event) { + LoginUserEvent() => _onLoginUser(event, emit), + }, + ); + } + final FirebaseAuth _authService; + + Future _onLoginUser(LoginUserEvent event, _Emit emit) async { + emit(state.copyWith(isLoading: true, errorMessage: null)); + try { + await _authService.signInWithEmailAndPassword( + email: event.email, + password: event.password, + ); + } on FirebaseAuthException catch (e) { + _log.severe( + 'Failed to sign in with FirebaseAuthException: $e :: ${e.message}', + ); + emit(state.copyWith(errorMessage: e.message, isLoading: false)); + } on Exception catch (e) { + _log.severe('Failed to sign in with Exception: $e'); + emit(state.copyWith(errorMessage: e.toString(), isLoading: false)); + } + } +} + +/// Actions that can be taken on the Login page. +@Freezed() +sealed class LoginEvent with _$LoginEvent { + /// Submitting an email and password. + const factory LoginEvent.login({ + required String email, + required String password, + }) = LoginUserEvent; +} + +/// {@template LoginState} +/// Complete representation of the Login page's state. +/// {@endtemplate +@Freezed() +sealed class LoginState with _$LoginState { + /// {@macro LoginState} + const factory LoginState({ + String? email, + String? password, + String? errorMessage, + @Default(false) bool isLoading, + }) = _LoginState; + const LoginState._(); + + /// Starter state fed to the [LoginBloc]. + factory LoginState.initial() => const LoginState(); +} diff --git a/genlatte/genlatte-ui/lib/src/screens/login/login_bloc.freezed.dart b/genlatte/genlatte-ui/lib/src/screens/login/login_bloc.freezed.dart new file mode 100644 index 0000000..272f154 --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/screens/login/login_bloc.freezed.dart @@ -0,0 +1,532 @@ +// Copyright 2014 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. + +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'login_bloc.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; +/// @nodoc +mixin _$LoginEvent { + + String get email; String get password; +/// Create a copy of LoginEvent +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$LoginEventCopyWith get copyWith => _$LoginEventCopyWithImpl(this as LoginEvent, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is LoginEvent&&(identical(other.email, email) || other.email == email)&&(identical(other.password, password) || other.password == password)); +} + + +@override +int get hashCode => Object.hash(runtimeType,email,password); + +@override +String toString() { + return 'LoginEvent(email: $email, password: $password)'; +} + + +} + +/// @nodoc +abstract mixin class $LoginEventCopyWith<$Res> { + factory $LoginEventCopyWith(LoginEvent value, $Res Function(LoginEvent) _then) = _$LoginEventCopyWithImpl; +@useResult +$Res call({ + String email, String password +}); + + + + +} +/// @nodoc +class _$LoginEventCopyWithImpl<$Res> + implements $LoginEventCopyWith<$Res> { + _$LoginEventCopyWithImpl(this._self, this._then); + + final LoginEvent _self; + final $Res Function(LoginEvent) _then; + +/// Create a copy of LoginEvent +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? email = null,Object? password = null,}) { + return _then(_self.copyWith( +email: null == email ? _self.email : email // ignore: cast_nullable_to_non_nullable +as String,password: null == password ? _self.password : password // ignore: cast_nullable_to_non_nullable +as String, + )); +} + +} + + +/// Adds pattern-matching-related methods to [LoginEvent]. +extension LoginEventPatterns on LoginEvent { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap({TResult Function( LoginUserEvent value)? login,required TResult orElse(),}){ +final _that = this; +switch (_that) { +case LoginUserEvent() when login != null: +return login(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map({required TResult Function( LoginUserEvent value) login,}){ +final _that = this; +switch (_that) { +case LoginUserEvent(): +return login(_that);} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull({TResult? Function( LoginUserEvent value)? login,}){ +final _that = this; +switch (_that) { +case LoginUserEvent() when login != null: +return login(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen({TResult Function( String email, String password)? login,required TResult orElse(),}) {final _that = this; +switch (_that) { +case LoginUserEvent() when login != null: +return login(_that.email,_that.password);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when({required TResult Function( String email, String password) login,}) {final _that = this; +switch (_that) { +case LoginUserEvent(): +return login(_that.email,_that.password);} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull({TResult? Function( String email, String password)? login,}) {final _that = this; +switch (_that) { +case LoginUserEvent() when login != null: +return login(_that.email,_that.password);case _: + return null; + +} +} + +} + +/// @nodoc + + +class LoginUserEvent implements LoginEvent { + const LoginUserEvent({required this.email, required this.password}); + + +@override final String email; +@override final String password; + +/// Create a copy of LoginEvent +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$LoginUserEventCopyWith get copyWith => _$LoginUserEventCopyWithImpl(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is LoginUserEvent&&(identical(other.email, email) || other.email == email)&&(identical(other.password, password) || other.password == password)); +} + + +@override +int get hashCode => Object.hash(runtimeType,email,password); + +@override +String toString() { + return 'LoginEvent.login(email: $email, password: $password)'; +} + + +} + +/// @nodoc +abstract mixin class $LoginUserEventCopyWith<$Res> implements $LoginEventCopyWith<$Res> { + factory $LoginUserEventCopyWith(LoginUserEvent value, $Res Function(LoginUserEvent) _then) = _$LoginUserEventCopyWithImpl; +@override @useResult +$Res call({ + String email, String password +}); + + + + +} +/// @nodoc +class _$LoginUserEventCopyWithImpl<$Res> + implements $LoginUserEventCopyWith<$Res> { + _$LoginUserEventCopyWithImpl(this._self, this._then); + + final LoginUserEvent _self; + final $Res Function(LoginUserEvent) _then; + +/// Create a copy of LoginEvent +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? email = null,Object? password = null,}) { + return _then(LoginUserEvent( +email: null == email ? _self.email : email // ignore: cast_nullable_to_non_nullable +as String,password: null == password ? _self.password : password // ignore: cast_nullable_to_non_nullable +as String, + )); +} + + +} + +/// @nodoc +mixin _$LoginState { + + String? get email; String? get password; String? get errorMessage; bool get isLoading; +/// Create a copy of LoginState +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$LoginStateCopyWith get copyWith => _$LoginStateCopyWithImpl(this as LoginState, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is LoginState&&(identical(other.email, email) || other.email == email)&&(identical(other.password, password) || other.password == password)&&(identical(other.errorMessage, errorMessage) || other.errorMessage == errorMessage)&&(identical(other.isLoading, isLoading) || other.isLoading == isLoading)); +} + + +@override +int get hashCode => Object.hash(runtimeType,email,password,errorMessage,isLoading); + +@override +String toString() { + return 'LoginState(email: $email, password: $password, errorMessage: $errorMessage, isLoading: $isLoading)'; +} + + +} + +/// @nodoc +abstract mixin class $LoginStateCopyWith<$Res> { + factory $LoginStateCopyWith(LoginState value, $Res Function(LoginState) _then) = _$LoginStateCopyWithImpl; +@useResult +$Res call({ + String? email, String? password, String? errorMessage, bool isLoading +}); + + + + +} +/// @nodoc +class _$LoginStateCopyWithImpl<$Res> + implements $LoginStateCopyWith<$Res> { + _$LoginStateCopyWithImpl(this._self, this._then); + + final LoginState _self; + final $Res Function(LoginState) _then; + +/// Create a copy of LoginState +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? email = freezed,Object? password = freezed,Object? errorMessage = freezed,Object? isLoading = null,}) { + return _then(_self.copyWith( +email: freezed == email ? _self.email : email // ignore: cast_nullable_to_non_nullable +as String?,password: freezed == password ? _self.password : password // ignore: cast_nullable_to_non_nullable +as String?,errorMessage: freezed == errorMessage ? _self.errorMessage : errorMessage // ignore: cast_nullable_to_non_nullable +as String?,isLoading: null == isLoading ? _self.isLoading : isLoading // ignore: cast_nullable_to_non_nullable +as bool, + )); +} + +} + + +/// Adds pattern-matching-related methods to [LoginState]. +extension LoginStatePatterns on LoginState { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _LoginState value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _LoginState() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _LoginState value) $default,){ +final _that = this; +switch (_that) { +case _LoginState(): +return $default(_that);} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _LoginState value)? $default,){ +final _that = this; +switch (_that) { +case _LoginState() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String? email, String? password, String? errorMessage, bool isLoading)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _LoginState() when $default != null: +return $default(_that.email,_that.password,_that.errorMessage,_that.isLoading);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String? email, String? password, String? errorMessage, bool isLoading) $default,) {final _that = this; +switch (_that) { +case _LoginState(): +return $default(_that.email,_that.password,_that.errorMessage,_that.isLoading);} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String? email, String? password, String? errorMessage, bool isLoading)? $default,) {final _that = this; +switch (_that) { +case _LoginState() when $default != null: +return $default(_that.email,_that.password,_that.errorMessage,_that.isLoading);case _: + return null; + +} +} + +} + +/// @nodoc + + +class _LoginState extends LoginState { + const _LoginState({this.email, this.password, this.errorMessage, this.isLoading = false}): super._(); + + +@override final String? email; +@override final String? password; +@override final String? errorMessage; +@override@JsonKey() final bool isLoading; + +/// Create a copy of LoginState +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$LoginStateCopyWith<_LoginState> get copyWith => __$LoginStateCopyWithImpl<_LoginState>(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _LoginState&&(identical(other.email, email) || other.email == email)&&(identical(other.password, password) || other.password == password)&&(identical(other.errorMessage, errorMessage) || other.errorMessage == errorMessage)&&(identical(other.isLoading, isLoading) || other.isLoading == isLoading)); +} + + +@override +int get hashCode => Object.hash(runtimeType,email,password,errorMessage,isLoading); + +@override +String toString() { + return 'LoginState(email: $email, password: $password, errorMessage: $errorMessage, isLoading: $isLoading)'; +} + + +} + +/// @nodoc +abstract mixin class _$LoginStateCopyWith<$Res> implements $LoginStateCopyWith<$Res> { + factory _$LoginStateCopyWith(_LoginState value, $Res Function(_LoginState) _then) = __$LoginStateCopyWithImpl; +@override @useResult +$Res call({ + String? email, String? password, String? errorMessage, bool isLoading +}); + + + + +} +/// @nodoc +class __$LoginStateCopyWithImpl<$Res> + implements _$LoginStateCopyWith<$Res> { + __$LoginStateCopyWithImpl(this._self, this._then); + + final _LoginState _self; + final $Res Function(_LoginState) _then; + +/// Create a copy of LoginState +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? email = freezed,Object? password = freezed,Object? errorMessage = freezed,Object? isLoading = null,}) { + return _then(_LoginState( +email: freezed == email ? _self.email : email // ignore: cast_nullable_to_non_nullable +as String?,password: freezed == password ? _self.password : password // ignore: cast_nullable_to_non_nullable +as String?,errorMessage: freezed == errorMessage ? _self.errorMessage : errorMessage // ignore: cast_nullable_to_non_nullable +as String?,isLoading: null == isLoading ? _self.isLoading : isLoading // ignore: cast_nullable_to_non_nullable +as bool, + )); +} + + +} + +// dart format on diff --git a/genlatte/genlatte-ui/lib/src/screens/login/login_view.dart b/genlatte/genlatte-ui/lib/src/screens/login/login_view.dart new file mode 100644 index 0000000..00ee091 --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/screens/login/login_view.dart @@ -0,0 +1,206 @@ +// Copyright 2014 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:math'; + +import 'package:firebase_auth/firebase_auth.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:genlatte/src/screens/app/app.dart'; +import 'package:genlatte/src/screens/login/login.dart'; +import 'package:genlatte/src/widgets/widgets.dart'; +import 'package:get_it/get_it.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +/// {@template LoginScreen} +/// Initial Login screen. +/// {@endtemplate} +class LoginScreen extends StatelessWidget { + /// {@macro LoginScreen} + const LoginScreen({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: AppColors.white, + headers: [ + AppBar( + title: const Text('Login').h3, + height: 50, + ), + ], + child: Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 600), + child: const Column( + mainAxisAlignment: .center, + crossAxisAlignment: .stretch, + children: [ + _LoginForm(key: ValueKey('login_form')), + ], + ), + ), + ), + ), + ); + } +} + +class _LoginForm extends StatefulWidget { + const _LoginForm({super.key}); + + @override + State<_LoginForm> createState() => _LoginFormState(); +} + +class _LoginFormState extends State<_LoginForm> { + final TextFieldKey _emailKey = const TextFieldKey('email'); + final TextFieldKey _passwordKey = const TextFieldKey('password'); + + late final LoginBloc bloc; + bool _obscurePassword = true; + + @override + void initState() { + super.initState(); + bloc = LoginBloc(GetIt.I()); + } + + @override + void dispose() { + bloc.close().ignore(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final themeData = Theme.of(context); + final rows = [ + FormField( + key: _emailKey, + label: const Text('Email').h3, + validator: const EmailValidator(), + child: const TextField( + autocorrect: false, + style: TextStyle(fontSize: 24), + ), + ), + FormField( + key: _passwordKey, + label: const Text('Password').h3, + validator: const LengthValidator(min: 8), + child: TextField( + autocorrect: false, + obscureText: _obscurePassword, + style: const TextStyle(fontSize: 24), + ), + ), + ]; + + return Theme( + data: themeData.copyWith( + colorScheme: () => themeData.colorScheme.copyWith( + border: () => AppColors.almostBlack, + ), + ), + child: ResponsiveSizedBox.builder( + aspectRatioClamp: (0.5, null, 2), + builder: (context, size) { + // True for narrower windows where we need to make better use + // of vertical space. + final labelsStacked = size.width < 800; + return Padding( + padding: labelsStacked + ? EdgeInsets.zero + : EdgeInsets.all(size.width * 0.05), + child: SizedBox( + width: labelsStacked ? max(480, size.width * 0.6) : size.width, + child: BlocBuilder( + bloc: bloc, + builder: (context, state) { + return Form( + onSubmit: (context, values) async { + final email = _emailKey[values]; + final password = _passwordKey[values]; + bloc.add( + LoginEvent.login( + email: email!, + password: password!, + ), + ); + }, + child: Column( + mainAxisSize: labelsStacked ? .max : .min, + crossAxisAlignment: labelsStacked + ? CrossAxisAlignment.stretch + : CrossAxisAlignment.end, + children: [ + if (labelsStacked) + Column( + crossAxisAlignment: .stretch, + children: rows, + ) + else + FormTableLayout(rows: rows), + const Gap(12), + Row( + children: [ + const Spacer(), + IconButton.ghost( + icon: Icon( + _obscurePassword + ? Icons.visibility_off + : Icons.visibility, + ), + onPressed: () { + setState(() { + _obscurePassword = !_obscurePassword; + }); + }, + ), + ], + ), + const Gap(24), + if (state.errorMessage != null) ...[ + Text( + state.errorMessage!, + style: const TextStyle( + color: AppColors.googleIntroRed, + ), + ).h4, + const Gap(24), + ], + FormErrorBuilder( + builder: (context, errors, child) { + if (state.isLoading) { + return const Center( + child: CircularProgressIndicator(), + ); + } + // Disable the submit button while there are + // validation errors. + return PrimaryButton( + onPressed: errors.isEmpty + ? () => context.submitForm() + : null, + child: const Text( + 'Login', + style: TextStyle(fontSize: 24), + ), + ); + }, + ), + ], + ), + ); + }, + ), + ), + ); + }, + ), + ); + } +} diff --git a/genlatte/genlatte-ui/lib/src/screens/moderator/CONTEXT.md b/genlatte/genlatte-ui/lib/src/screens/moderator/CONTEXT.md new file mode 100644 index 0000000..04df99c --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/screens/moderator/CONTEXT.md @@ -0,0 +1,11 @@ +# Kiosk / Barista / Moderator / Queue / Recent Orders Roots + +**Purpose:** +This directory acts as the architectural boundary grouping for the application's distinct personas. + +**Implementation Details:** +- **Barrel Architecture**: This level strictly exports child module directories like `home/` and `widgets/` via an `index` / `barrel` file. +- **Child Contexts**: Please navigate into `home/` or `widgets/` subdirectories to view specific BLoC architectures, View layouts, and complex logic files tailored towards each app persona. + +**Dependencies:** +- Consumed by `core/routing/routes.dart` to map physical URLs to specific screen entry points. diff --git a/genlatte/genlatte-ui/lib/src/screens/moderator/home/CONTEXT.md b/genlatte/genlatte-ui/lib/src/screens/moderator/home/CONTEXT.md new file mode 100644 index 0000000..b838fea --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/screens/moderator/home/CONTEXT.md @@ -0,0 +1,22 @@ +# Moderator Home + +**Purpose:** +Provides the logic and UI for the Moderator dashboard. This screen aims to give moderators an overview of the entire latte bar, handling primarily the queue of submitted orders that require image or name safety moderation. + +**Detailed File Overviews:** + +- `moderator_home.dart`: + - **Description**: Barrel export file. + +- `moderator_home_bloc.dart`: + - **Description**: The BLoC managing moderator state via stream subscriptions. + - **Core Logic**: Subscribes to changes in `Barista` entities and `LatteOrderMetadata` using `RequestDetails(filter: LatteOrderMetadataModerationQueue())`. Exposes methods to handle approval or rejection flows for Names and Images via `_moderateOrder()` which toggles server validation status (`status: .validated`). It merges both moderation and brew queues sequentially to provide a full bar snapshot. + +- `moderator_home_view.dart`: + - **Description**: The primary UI for the Moderator Home screen. + - **Core Logic**: Constructs a Scaffold rendering an `InternalOrderQueues.moderator` instance. It pipes callbacks from the Queue cards into the Bloc events (`ApproveNameAndImage`, `RejectNameApproveImage`, `ApproveNameRejectImage`, `RejectNameAndImage`). Also includes an AppBar routing over to the `` via a print icon. + +**Dependencies/Relationships:** +- Closely tied to `genlatte_data` Models (`Barista`, `LatteOrderMetadata`). +- Relies heavily on `barista/widgets` (especially `InternalOrderQueues` and `LatteOrderCard.moderator`) for its core visualization. +- Connected to `AppRouter` to navigate to `machines/`. diff --git a/genlatte/genlatte-ui/lib/src/screens/moderator/home/moderator_home.dart b/genlatte/genlatte-ui/lib/src/screens/moderator/home/moderator_home.dart new file mode 100644 index 0000000..1f1cbfa --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/screens/moderator/home/moderator_home.dart @@ -0,0 +1,6 @@ +// Copyright 2014 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 'moderator_home_bloc.dart'; +export 'moderator_home_view.dart'; diff --git a/genlatte/genlatte-ui/lib/src/screens/moderator/home/moderator_home_bloc.dart b/genlatte/genlatte-ui/lib/src/screens/moderator/home/moderator_home_bloc.dart new file mode 100644 index 0000000..409275b --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/screens/moderator/home/moderator_home_bloc.dart @@ -0,0 +1,279 @@ +// Copyright 2014 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:data_layer/data_layer.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:genlatte/src/data/data.dart'; +import 'package:genlatte_data/models.dart'; +import 'package:get_it/get_it.dart'; +import 'package:logging/logging.dart'; + +part 'moderator_home_bloc.freezed.dart'; + +final _logger = Logger('ModeratorHomeBloc'); + +typedef _Emit = Emitter; + +/// {@template ModeratorHomeBloc} +/// Bloc for the Moderator home page. +/// {@endtemplate} +class ModeratorHomeBloc extends Bloc { + /// {@macro ModeratorHomeBloc} + ModeratorHomeBloc() : super(ModeratorHomeState.initial()) { + on( + (event, _Emit emit) => switch (event) { + NewBaristas() => _onNewBaristas(event, emit), + NewModerateQueueOrders() => _onNewModerateQueueOrders(event, emit), + ApproveNameAndImage() => _onApproveNameAndImage(event, emit), + RejectNameApproveImage() => _onRejectNameApproveImage(event, emit), + ApproveNameRejectImage() => _onApproveNameRejectImage(event, emit), + RejectNameAndImage() => _onRejectNameAndImage(event, emit), + CompleteOrder() => _onCompleteOrder(event, emit), + }, + ); + + _baristasRepository = GetIt.I>(); + _metadataRepository = GetIt.I>(); + _ordersRepository = GetIt.I(); + + _moderateStreamSub = _metadataRepository + .watchList( + details: RequestDetails.read( + filter: const LatteOrderMetadataModerationQueue(), + ), + ) + .listen((metadata) => add(NewModerateQueueOrders(metadata))); + + _baristaStreamSub = _baristasRepository.watchList().listen( + (baristas) => add(NewBaristas(baristas)), + ); + } + + late final Repository _baristasRepository; + late final LatteOrdersRepository _ordersRepository; + late final Repository _metadataRepository; + + late final StreamSubscription> _moderateStreamSub; + late final StreamSubscription> _baristaStreamSub; + + Future _onNewModerateQueueOrders( + NewModerateQueueOrders event, + _Emit emit, + ) async { + _logger.fine( + 'New moderation queue: ${event.metadatas.map((m) => m.id!)}', + ); + + List lattes; + try { + lattes = await _ordersRepository.toLattes(event.metadatas); + } on DataException catch (e) { + _logger.severe(e.message); + return; + } + + final moderationLattes = []; + final brewLattes = []; + + for (final latte in lattes) { + if (latte.metadata.status == LatteOrderStatus.submitted) { + moderationLattes.add(latte); + } else { + brewLattes.add(latte); + } + } + + emit( + state.copyWith( + moderationQueue: [ + // Older orders appear first + ...moderationLattes..sort( + (a, b) => a.metadata.orderSubmittedTime!.compareTo( + b.metadata.orderSubmittedTime!, + ), + ), + // Older orders appear first + ...brewLattes..sort( + (a, b) => a.metadata.orderSubmittedTime!.compareTo( + b.metadata.orderSubmittedTime!, + ), + ), + ], + ), + ); + } + + void _onNewBaristas(NewBaristas event, _Emit emit) { + _logger.fine('New baristas queue: ${event.baristas}'); + emit(state.copyWith(baristas: event.baristas.toIdsMap())); + } + + Future _onApproveNameAndImage( + ApproveNameAndImage event, + _Emit emit, + ) => _moderateOrder( + orderId: event.orderId, + isNameApproved: true, + isImageApproved: true, + ); + + Future _onRejectNameApproveImage( + RejectNameApproveImage event, + _Emit emit, + ) => _moderateOrder( + orderId: event.orderId, + isNameApproved: false, + isImageApproved: true, + ); + + Future _onApproveNameRejectImage( + ApproveNameRejectImage event, + _Emit emit, + ) => _moderateOrder( + orderId: event.orderId, + isNameApproved: true, + isImageApproved: false, + ); + + Future _onRejectNameAndImage( + RejectNameAndImage event, + _Emit emit, + ) => _moderateOrder( + orderId: event.orderId, + isNameApproved: false, + isImageApproved: false, + ); + + Future _moderateOrder({ + required String orderId, + required bool isNameApproved, + required bool isImageApproved, + }) async { + final latte = state.moderationQueue.firstWhereOrNull( + (l) => l.order.id == orderId, + ); + + if (latte == null) { + _logger.warning( + 'Could not find latte for order id $orderId. ' + 'Maybe another barista moderated it at the same time?', + ); + return; + } + + final metadata = latte.metadata.copyWith( + isNameApproved: isNameApproved, + isImageApproved: isImageApproved, + status: .validated, + ); + + // No need to capture the returned metadata, since it will get picked up + // by our open `watch` streams. + await _metadataRepository.setItem(metadata); + } + + Future _onCompleteOrder(CompleteOrder event, _Emit emit) async { + final latte = state.moderationQueue.firstWhereOrNull( + (l) => l.order.id == event.orderId, + ); + + if (latte == null) { + _logger.warning( + 'Could not find latte for order id ${event.orderId}. ' + 'Maybe another moderator or barista completed it at the same time?', + ); + return; + } + + /// Optimistic update to immediately remove the order from the UI. + emit( + state.copyWith( + moderationQueue: state.moderationQueue + .where((l) => l.order.id != event.orderId) + .toList(), + ), + ); + + await _ordersRepository.completeOrder( + orderId: latte.order.id!, + // Since moderators don't have a "persona" and thus don't have a + // Barista record, we'll just use a special string to indicate that + // a moderator completed the order. + baristaId: 'moderator', + ); + } + + @override + Future close() { + _moderateStreamSub.cancel().ignore(); + _baristaStreamSub.cancel().ignore(); + return super.close(); + } +} + +/// Actions that can be taken on the ModeratorHome page. +@Freezed() +sealed class ModeratorHomeEvent with _$ModeratorHomeEvent { + /// A new list of Baristas has arrived from the server. + const factory ModeratorHomeEvent.newBaristas(List baristas) = + NewBaristas; + + /// A new list of ready-to-moderate orders has arrived from the server. + const factory ModeratorHomeEvent.newModerateQueueOrders( + List metadatas, + ) = NewModerateQueueOrders; + + /// The barista has approved both the name and image for an order. + const factory ModeratorHomeEvent.approveNameAndImage(String orderId) = + ApproveNameAndImage; + + // /// The barista has rejected the name for an order. + const factory ModeratorHomeEvent.rejectNameApproveImage(String orderId) = + RejectNameApproveImage; + + // /// The barista has rejected the name for an order. + const factory ModeratorHomeEvent.approveNameRejectImage(String orderId) = + ApproveNameRejectImage; + + // /// The barista has rejected both the name and image for an order. + const factory ModeratorHomeEvent.rejectNameAndImage(String orderId) = + RejectNameAndImage; + + /// The moderator has completed an order. + const factory ModeratorHomeEvent.completeOrder(String orderId) = CompleteOrder; +} + +/// {@template ModeratorHomeState} +/// Complete representation of the ModeratorHome page's state. +/// {@endtemplate +@Freezed() +sealed class ModeratorHomeState with _$ModeratorHomeState { + /// {@macro ModeratorHomeState} + const factory ModeratorHomeState({ + required List moderationQueue, + @Default({}) Map baristas, + }) = _ModeratorHomeState; + const ModeratorHomeState._(); + + /// Starter state fed to the [ModeratorHomeBloc]. + factory ModeratorHomeState.initial({ + List? moderationQueue, + }) => ModeratorHomeState( + moderationQueue: moderationQueue ?? [], + ); +} + +extension _MyList on List { + /// Returns the first matching element, or null if there are no matches.` + E? firstWhereOrNull(bool Function(E element) test, {E? Function()? orElse}) { + for (final E element in this) { + if (test(element)) return element; + } + if (orElse != null) return orElse(); + return null; + } +} diff --git a/genlatte/genlatte-ui/lib/src/screens/moderator/home/moderator_home_bloc.freezed.dart b/genlatte/genlatte-ui/lib/src/screens/moderator/home/moderator_home_bloc.freezed.dart new file mode 100644 index 0000000..3105bd5 --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/screens/moderator/home/moderator_home_bloc.freezed.dart @@ -0,0 +1,948 @@ +// Copyright 2014 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. + +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'moderator_home_bloc.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; +/// @nodoc +mixin _$ModeratorHomeEvent { + + + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is ModeratorHomeEvent); +} + + +@override +int get hashCode => runtimeType.hashCode; + +@override +String toString() { + return 'ModeratorHomeEvent()'; +} + + +} + +/// @nodoc +class $ModeratorHomeEventCopyWith<$Res> { +$ModeratorHomeEventCopyWith(ModeratorHomeEvent _, $Res Function(ModeratorHomeEvent) __); +} + + +/// Adds pattern-matching-related methods to [ModeratorHomeEvent]. +extension ModeratorHomeEventPatterns on ModeratorHomeEvent { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap({TResult Function( NewBaristas value)? newBaristas,TResult Function( NewModerateQueueOrders value)? newModerateQueueOrders,TResult Function( ApproveNameAndImage value)? approveNameAndImage,TResult Function( RejectNameApproveImage value)? rejectNameApproveImage,TResult Function( ApproveNameRejectImage value)? approveNameRejectImage,TResult Function( RejectNameAndImage value)? rejectNameAndImage,TResult Function( CompleteOrder value)? completeOrder,required TResult orElse(),}){ +final _that = this; +switch (_that) { +case NewBaristas() when newBaristas != null: +return newBaristas(_that);case NewModerateQueueOrders() when newModerateQueueOrders != null: +return newModerateQueueOrders(_that);case ApproveNameAndImage() when approveNameAndImage != null: +return approveNameAndImage(_that);case RejectNameApproveImage() when rejectNameApproveImage != null: +return rejectNameApproveImage(_that);case ApproveNameRejectImage() when approveNameRejectImage != null: +return approveNameRejectImage(_that);case RejectNameAndImage() when rejectNameAndImage != null: +return rejectNameAndImage(_that);case CompleteOrder() when completeOrder != null: +return completeOrder(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map({required TResult Function( NewBaristas value) newBaristas,required TResult Function( NewModerateQueueOrders value) newModerateQueueOrders,required TResult Function( ApproveNameAndImage value) approveNameAndImage,required TResult Function( RejectNameApproveImage value) rejectNameApproveImage,required TResult Function( ApproveNameRejectImage value) approveNameRejectImage,required TResult Function( RejectNameAndImage value) rejectNameAndImage,required TResult Function( CompleteOrder value) completeOrder,}){ +final _that = this; +switch (_that) { +case NewBaristas(): +return newBaristas(_that);case NewModerateQueueOrders(): +return newModerateQueueOrders(_that);case ApproveNameAndImage(): +return approveNameAndImage(_that);case RejectNameApproveImage(): +return rejectNameApproveImage(_that);case ApproveNameRejectImage(): +return approveNameRejectImage(_that);case RejectNameAndImage(): +return rejectNameAndImage(_that);case CompleteOrder(): +return completeOrder(_that);} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull({TResult? Function( NewBaristas value)? newBaristas,TResult? Function( NewModerateQueueOrders value)? newModerateQueueOrders,TResult? Function( ApproveNameAndImage value)? approveNameAndImage,TResult? Function( RejectNameApproveImage value)? rejectNameApproveImage,TResult? Function( ApproveNameRejectImage value)? approveNameRejectImage,TResult? Function( RejectNameAndImage value)? rejectNameAndImage,TResult? Function( CompleteOrder value)? completeOrder,}){ +final _that = this; +switch (_that) { +case NewBaristas() when newBaristas != null: +return newBaristas(_that);case NewModerateQueueOrders() when newModerateQueueOrders != null: +return newModerateQueueOrders(_that);case ApproveNameAndImage() when approveNameAndImage != null: +return approveNameAndImage(_that);case RejectNameApproveImage() when rejectNameApproveImage != null: +return rejectNameApproveImage(_that);case ApproveNameRejectImage() when approveNameRejectImage != null: +return approveNameRejectImage(_that);case RejectNameAndImage() when rejectNameAndImage != null: +return rejectNameAndImage(_that);case CompleteOrder() when completeOrder != null: +return completeOrder(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen({TResult Function( List baristas)? newBaristas,TResult Function( List metadatas)? newModerateQueueOrders,TResult Function( String orderId)? approveNameAndImage,TResult Function( String orderId)? rejectNameApproveImage,TResult Function( String orderId)? approveNameRejectImage,TResult Function( String orderId)? rejectNameAndImage,TResult Function( String orderId)? completeOrder,required TResult orElse(),}) {final _that = this; +switch (_that) { +case NewBaristas() when newBaristas != null: +return newBaristas(_that.baristas);case NewModerateQueueOrders() when newModerateQueueOrders != null: +return newModerateQueueOrders(_that.metadatas);case ApproveNameAndImage() when approveNameAndImage != null: +return approveNameAndImage(_that.orderId);case RejectNameApproveImage() when rejectNameApproveImage != null: +return rejectNameApproveImage(_that.orderId);case ApproveNameRejectImage() when approveNameRejectImage != null: +return approveNameRejectImage(_that.orderId);case RejectNameAndImage() when rejectNameAndImage != null: +return rejectNameAndImage(_that.orderId);case CompleteOrder() when completeOrder != null: +return completeOrder(_that.orderId);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when({required TResult Function( List baristas) newBaristas,required TResult Function( List metadatas) newModerateQueueOrders,required TResult Function( String orderId) approveNameAndImage,required TResult Function( String orderId) rejectNameApproveImage,required TResult Function( String orderId) approveNameRejectImage,required TResult Function( String orderId) rejectNameAndImage,required TResult Function( String orderId) completeOrder,}) {final _that = this; +switch (_that) { +case NewBaristas(): +return newBaristas(_that.baristas);case NewModerateQueueOrders(): +return newModerateQueueOrders(_that.metadatas);case ApproveNameAndImage(): +return approveNameAndImage(_that.orderId);case RejectNameApproveImage(): +return rejectNameApproveImage(_that.orderId);case ApproveNameRejectImage(): +return approveNameRejectImage(_that.orderId);case RejectNameAndImage(): +return rejectNameAndImage(_that.orderId);case CompleteOrder(): +return completeOrder(_that.orderId);} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull({TResult? Function( List baristas)? newBaristas,TResult? Function( List metadatas)? newModerateQueueOrders,TResult? Function( String orderId)? approveNameAndImage,TResult? Function( String orderId)? rejectNameApproveImage,TResult? Function( String orderId)? approveNameRejectImage,TResult? Function( String orderId)? rejectNameAndImage,TResult? Function( String orderId)? completeOrder,}) {final _that = this; +switch (_that) { +case NewBaristas() when newBaristas != null: +return newBaristas(_that.baristas);case NewModerateQueueOrders() when newModerateQueueOrders != null: +return newModerateQueueOrders(_that.metadatas);case ApproveNameAndImage() when approveNameAndImage != null: +return approveNameAndImage(_that.orderId);case RejectNameApproveImage() when rejectNameApproveImage != null: +return rejectNameApproveImage(_that.orderId);case ApproveNameRejectImage() when approveNameRejectImage != null: +return approveNameRejectImage(_that.orderId);case RejectNameAndImage() when rejectNameAndImage != null: +return rejectNameAndImage(_that.orderId);case CompleteOrder() when completeOrder != null: +return completeOrder(_that.orderId);case _: + return null; + +} +} + +} + +/// @nodoc + + +class NewBaristas implements ModeratorHomeEvent { + const NewBaristas(final List baristas): _baristas = baristas; + + + final List _baristas; + List get baristas { + if (_baristas is EqualUnmodifiableListView) return _baristas; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_baristas); +} + + +/// Create a copy of ModeratorHomeEvent +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$NewBaristasCopyWith get copyWith => _$NewBaristasCopyWithImpl(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is NewBaristas&&const DeepCollectionEquality().equals(other._baristas, _baristas)); +} + + +@override +int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(_baristas)); + +@override +String toString() { + return 'ModeratorHomeEvent.newBaristas(baristas: $baristas)'; +} + + +} + +/// @nodoc +abstract mixin class $NewBaristasCopyWith<$Res> implements $ModeratorHomeEventCopyWith<$Res> { + factory $NewBaristasCopyWith(NewBaristas value, $Res Function(NewBaristas) _then) = _$NewBaristasCopyWithImpl; +@useResult +$Res call({ + List baristas +}); + + + + +} +/// @nodoc +class _$NewBaristasCopyWithImpl<$Res> + implements $NewBaristasCopyWith<$Res> { + _$NewBaristasCopyWithImpl(this._self, this._then); + + final NewBaristas _self; + final $Res Function(NewBaristas) _then; + +/// Create a copy of ModeratorHomeEvent +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') $Res call({Object? baristas = null,}) { + return _then(NewBaristas( +null == baristas ? _self._baristas : baristas // ignore: cast_nullable_to_non_nullable +as List, + )); +} + + +} + +/// @nodoc + + +class NewModerateQueueOrders implements ModeratorHomeEvent { + const NewModerateQueueOrders(final List metadatas): _metadatas = metadatas; + + + final List _metadatas; + List get metadatas { + if (_metadatas is EqualUnmodifiableListView) return _metadatas; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_metadatas); +} + + +/// Create a copy of ModeratorHomeEvent +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$NewModerateQueueOrdersCopyWith get copyWith => _$NewModerateQueueOrdersCopyWithImpl(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is NewModerateQueueOrders&&const DeepCollectionEquality().equals(other._metadatas, _metadatas)); +} + + +@override +int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(_metadatas)); + +@override +String toString() { + return 'ModeratorHomeEvent.newModerateQueueOrders(metadatas: $metadatas)'; +} + + +} + +/// @nodoc +abstract mixin class $NewModerateQueueOrdersCopyWith<$Res> implements $ModeratorHomeEventCopyWith<$Res> { + factory $NewModerateQueueOrdersCopyWith(NewModerateQueueOrders value, $Res Function(NewModerateQueueOrders) _then) = _$NewModerateQueueOrdersCopyWithImpl; +@useResult +$Res call({ + List metadatas +}); + + + + +} +/// @nodoc +class _$NewModerateQueueOrdersCopyWithImpl<$Res> + implements $NewModerateQueueOrdersCopyWith<$Res> { + _$NewModerateQueueOrdersCopyWithImpl(this._self, this._then); + + final NewModerateQueueOrders _self; + final $Res Function(NewModerateQueueOrders) _then; + +/// Create a copy of ModeratorHomeEvent +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') $Res call({Object? metadatas = null,}) { + return _then(NewModerateQueueOrders( +null == metadatas ? _self._metadatas : metadatas // ignore: cast_nullable_to_non_nullable +as List, + )); +} + + +} + +/// @nodoc + + +class ApproveNameAndImage implements ModeratorHomeEvent { + const ApproveNameAndImage(this.orderId); + + + final String orderId; + +/// Create a copy of ModeratorHomeEvent +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$ApproveNameAndImageCopyWith get copyWith => _$ApproveNameAndImageCopyWithImpl(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is ApproveNameAndImage&&(identical(other.orderId, orderId) || other.orderId == orderId)); +} + + +@override +int get hashCode => Object.hash(runtimeType,orderId); + +@override +String toString() { + return 'ModeratorHomeEvent.approveNameAndImage(orderId: $orderId)'; +} + + +} + +/// @nodoc +abstract mixin class $ApproveNameAndImageCopyWith<$Res> implements $ModeratorHomeEventCopyWith<$Res> { + factory $ApproveNameAndImageCopyWith(ApproveNameAndImage value, $Res Function(ApproveNameAndImage) _then) = _$ApproveNameAndImageCopyWithImpl; +@useResult +$Res call({ + String orderId +}); + + + + +} +/// @nodoc +class _$ApproveNameAndImageCopyWithImpl<$Res> + implements $ApproveNameAndImageCopyWith<$Res> { + _$ApproveNameAndImageCopyWithImpl(this._self, this._then); + + final ApproveNameAndImage _self; + final $Res Function(ApproveNameAndImage) _then; + +/// Create a copy of ModeratorHomeEvent +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') $Res call({Object? orderId = null,}) { + return _then(ApproveNameAndImage( +null == orderId ? _self.orderId : orderId // ignore: cast_nullable_to_non_nullable +as String, + )); +} + + +} + +/// @nodoc + + +class RejectNameApproveImage implements ModeratorHomeEvent { + const RejectNameApproveImage(this.orderId); + + + final String orderId; + +/// Create a copy of ModeratorHomeEvent +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$RejectNameApproveImageCopyWith get copyWith => _$RejectNameApproveImageCopyWithImpl(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is RejectNameApproveImage&&(identical(other.orderId, orderId) || other.orderId == orderId)); +} + + +@override +int get hashCode => Object.hash(runtimeType,orderId); + +@override +String toString() { + return 'ModeratorHomeEvent.rejectNameApproveImage(orderId: $orderId)'; +} + + +} + +/// @nodoc +abstract mixin class $RejectNameApproveImageCopyWith<$Res> implements $ModeratorHomeEventCopyWith<$Res> { + factory $RejectNameApproveImageCopyWith(RejectNameApproveImage value, $Res Function(RejectNameApproveImage) _then) = _$RejectNameApproveImageCopyWithImpl; +@useResult +$Res call({ + String orderId +}); + + + + +} +/// @nodoc +class _$RejectNameApproveImageCopyWithImpl<$Res> + implements $RejectNameApproveImageCopyWith<$Res> { + _$RejectNameApproveImageCopyWithImpl(this._self, this._then); + + final RejectNameApproveImage _self; + final $Res Function(RejectNameApproveImage) _then; + +/// Create a copy of ModeratorHomeEvent +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') $Res call({Object? orderId = null,}) { + return _then(RejectNameApproveImage( +null == orderId ? _self.orderId : orderId // ignore: cast_nullable_to_non_nullable +as String, + )); +} + + +} + +/// @nodoc + + +class ApproveNameRejectImage implements ModeratorHomeEvent { + const ApproveNameRejectImage(this.orderId); + + + final String orderId; + +/// Create a copy of ModeratorHomeEvent +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$ApproveNameRejectImageCopyWith get copyWith => _$ApproveNameRejectImageCopyWithImpl(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is ApproveNameRejectImage&&(identical(other.orderId, orderId) || other.orderId == orderId)); +} + + +@override +int get hashCode => Object.hash(runtimeType,orderId); + +@override +String toString() { + return 'ModeratorHomeEvent.approveNameRejectImage(orderId: $orderId)'; +} + + +} + +/// @nodoc +abstract mixin class $ApproveNameRejectImageCopyWith<$Res> implements $ModeratorHomeEventCopyWith<$Res> { + factory $ApproveNameRejectImageCopyWith(ApproveNameRejectImage value, $Res Function(ApproveNameRejectImage) _then) = _$ApproveNameRejectImageCopyWithImpl; +@useResult +$Res call({ + String orderId +}); + + + + +} +/// @nodoc +class _$ApproveNameRejectImageCopyWithImpl<$Res> + implements $ApproveNameRejectImageCopyWith<$Res> { + _$ApproveNameRejectImageCopyWithImpl(this._self, this._then); + + final ApproveNameRejectImage _self; + final $Res Function(ApproveNameRejectImage) _then; + +/// Create a copy of ModeratorHomeEvent +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') $Res call({Object? orderId = null,}) { + return _then(ApproveNameRejectImage( +null == orderId ? _self.orderId : orderId // ignore: cast_nullable_to_non_nullable +as String, + )); +} + + +} + +/// @nodoc + + +class RejectNameAndImage implements ModeratorHomeEvent { + const RejectNameAndImage(this.orderId); + + + final String orderId; + +/// Create a copy of ModeratorHomeEvent +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$RejectNameAndImageCopyWith get copyWith => _$RejectNameAndImageCopyWithImpl(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is RejectNameAndImage&&(identical(other.orderId, orderId) || other.orderId == orderId)); +} + + +@override +int get hashCode => Object.hash(runtimeType,orderId); + +@override +String toString() { + return 'ModeratorHomeEvent.rejectNameAndImage(orderId: $orderId)'; +} + + +} + +/// @nodoc +abstract mixin class $RejectNameAndImageCopyWith<$Res> implements $ModeratorHomeEventCopyWith<$Res> { + factory $RejectNameAndImageCopyWith(RejectNameAndImage value, $Res Function(RejectNameAndImage) _then) = _$RejectNameAndImageCopyWithImpl; +@useResult +$Res call({ + String orderId +}); + + + + +} +/// @nodoc +class _$RejectNameAndImageCopyWithImpl<$Res> + implements $RejectNameAndImageCopyWith<$Res> { + _$RejectNameAndImageCopyWithImpl(this._self, this._then); + + final RejectNameAndImage _self; + final $Res Function(RejectNameAndImage) _then; + +/// Create a copy of ModeratorHomeEvent +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') $Res call({Object? orderId = null,}) { + return _then(RejectNameAndImage( +null == orderId ? _self.orderId : orderId // ignore: cast_nullable_to_non_nullable +as String, + )); +} + + +} + +/// @nodoc + + +class CompleteOrder implements ModeratorHomeEvent { + const CompleteOrder(this.orderId); + + + final String orderId; + +/// Create a copy of ModeratorHomeEvent +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$CompleteOrderCopyWith get copyWith => _$CompleteOrderCopyWithImpl(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is CompleteOrder&&(identical(other.orderId, orderId) || other.orderId == orderId)); +} + + +@override +int get hashCode => Object.hash(runtimeType,orderId); + +@override +String toString() { + return 'ModeratorHomeEvent.completeOrder(orderId: $orderId)'; +} + + +} + +/// @nodoc +abstract mixin class $CompleteOrderCopyWith<$Res> implements $ModeratorHomeEventCopyWith<$Res> { + factory $CompleteOrderCopyWith(CompleteOrder value, $Res Function(CompleteOrder) _then) = _$CompleteOrderCopyWithImpl; +@useResult +$Res call({ + String orderId +}); + + + + +} +/// @nodoc +class _$CompleteOrderCopyWithImpl<$Res> + implements $CompleteOrderCopyWith<$Res> { + _$CompleteOrderCopyWithImpl(this._self, this._then); + + final CompleteOrder _self; + final $Res Function(CompleteOrder) _then; + +/// Create a copy of ModeratorHomeEvent +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') $Res call({Object? orderId = null,}) { + return _then(CompleteOrder( +null == orderId ? _self.orderId : orderId // ignore: cast_nullable_to_non_nullable +as String, + )); +} + + +} + +/// @nodoc +mixin _$ModeratorHomeState { + + List get moderationQueue; Map get baristas; +/// Create a copy of ModeratorHomeState +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$ModeratorHomeStateCopyWith get copyWith => _$ModeratorHomeStateCopyWithImpl(this as ModeratorHomeState, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is ModeratorHomeState&&const DeepCollectionEquality().equals(other.moderationQueue, moderationQueue)&&const DeepCollectionEquality().equals(other.baristas, baristas)); +} + + +@override +int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(moderationQueue),const DeepCollectionEquality().hash(baristas)); + +@override +String toString() { + return 'ModeratorHomeState(moderationQueue: $moderationQueue, baristas: $baristas)'; +} + + +} + +/// @nodoc +abstract mixin class $ModeratorHomeStateCopyWith<$Res> { + factory $ModeratorHomeStateCopyWith(ModeratorHomeState value, $Res Function(ModeratorHomeState) _then) = _$ModeratorHomeStateCopyWithImpl; +@useResult +$Res call({ + List moderationQueue, Map baristas +}); + + + + +} +/// @nodoc +class _$ModeratorHomeStateCopyWithImpl<$Res> + implements $ModeratorHomeStateCopyWith<$Res> { + _$ModeratorHomeStateCopyWithImpl(this._self, this._then); + + final ModeratorHomeState _self; + final $Res Function(ModeratorHomeState) _then; + +/// Create a copy of ModeratorHomeState +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? moderationQueue = null,Object? baristas = null,}) { + return _then(_self.copyWith( +moderationQueue: null == moderationQueue ? _self.moderationQueue : moderationQueue // ignore: cast_nullable_to_non_nullable +as List,baristas: null == baristas ? _self.baristas : baristas // ignore: cast_nullable_to_non_nullable +as Map, + )); +} + +} + + +/// Adds pattern-matching-related methods to [ModeratorHomeState]. +extension ModeratorHomeStatePatterns on ModeratorHomeState { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _ModeratorHomeState value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _ModeratorHomeState() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _ModeratorHomeState value) $default,){ +final _that = this; +switch (_that) { +case _ModeratorHomeState(): +return $default(_that);} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _ModeratorHomeState value)? $default,){ +final _that = this; +switch (_that) { +case _ModeratorHomeState() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( List moderationQueue, Map baristas)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _ModeratorHomeState() when $default != null: +return $default(_that.moderationQueue,_that.baristas);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( List moderationQueue, Map baristas) $default,) {final _that = this; +switch (_that) { +case _ModeratorHomeState(): +return $default(_that.moderationQueue,_that.baristas);} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( List moderationQueue, Map baristas)? $default,) {final _that = this; +switch (_that) { +case _ModeratorHomeState() when $default != null: +return $default(_that.moderationQueue,_that.baristas);case _: + return null; + +} +} + +} + +/// @nodoc + + +class _ModeratorHomeState extends ModeratorHomeState { + const _ModeratorHomeState({required final List moderationQueue, final Map baristas = const {}}): _moderationQueue = moderationQueue,_baristas = baristas,super._(); + + + final List _moderationQueue; +@override List get moderationQueue { + if (_moderationQueue is EqualUnmodifiableListView) return _moderationQueue; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_moderationQueue); +} + + final Map _baristas; +@override@JsonKey() Map get baristas { + if (_baristas is EqualUnmodifiableMapView) return _baristas; + // ignore: implicit_dynamic_type + return EqualUnmodifiableMapView(_baristas); +} + + +/// Create a copy of ModeratorHomeState +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$ModeratorHomeStateCopyWith<_ModeratorHomeState> get copyWith => __$ModeratorHomeStateCopyWithImpl<_ModeratorHomeState>(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _ModeratorHomeState&&const DeepCollectionEquality().equals(other._moderationQueue, _moderationQueue)&&const DeepCollectionEquality().equals(other._baristas, _baristas)); +} + + +@override +int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(_moderationQueue),const DeepCollectionEquality().hash(_baristas)); + +@override +String toString() { + return 'ModeratorHomeState(moderationQueue: $moderationQueue, baristas: $baristas)'; +} + + +} + +/// @nodoc +abstract mixin class _$ModeratorHomeStateCopyWith<$Res> implements $ModeratorHomeStateCopyWith<$Res> { + factory _$ModeratorHomeStateCopyWith(_ModeratorHomeState value, $Res Function(_ModeratorHomeState) _then) = __$ModeratorHomeStateCopyWithImpl; +@override @useResult +$Res call({ + List moderationQueue, Map baristas +}); + + + + +} +/// @nodoc +class __$ModeratorHomeStateCopyWithImpl<$Res> + implements _$ModeratorHomeStateCopyWith<$Res> { + __$ModeratorHomeStateCopyWithImpl(this._self, this._then); + + final _ModeratorHomeState _self; + final $Res Function(_ModeratorHomeState) _then; + +/// Create a copy of ModeratorHomeState +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? moderationQueue = null,Object? baristas = null,}) { + return _then(_ModeratorHomeState( +moderationQueue: null == moderationQueue ? _self._moderationQueue : moderationQueue // ignore: cast_nullable_to_non_nullable +as List,baristas: null == baristas ? _self._baristas : baristas // ignore: cast_nullable_to_non_nullable +as Map, + )); +} + + +} + +// dart format on diff --git a/genlatte/genlatte-ui/lib/src/screens/moderator/home/moderator_home_view.dart b/genlatte/genlatte-ui/lib/src/screens/moderator/home/moderator_home_view.dart new file mode 100644 index 0000000..4e36cfa --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/screens/moderator/home/moderator_home_view.dart @@ -0,0 +1,106 @@ +// Copyright 2014 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_analytics/firebase_analytics.dart'; +import 'package:firebase_auth/firebase_auth.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:genlatte/src/core/core.dart'; +import 'package:genlatte/src/screens/app/app.dart'; +import 'package:genlatte/src/screens/barista/widgets/widgets.dart'; +import 'package:genlatte/src/screens/moderator/home/moderator_home.dart'; +import 'package:genlatte/src/widgets/widgets.dart'; +import 'package:get_it/get_it.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +/// {@template ModeratorHomeScreen} +/// Initial ModeratorHome screen. +/// {@endtemplate} +class ModeratorHomeScreen extends StatefulWidget { + /// {@macro ModeratorHomeScreen} + const ModeratorHomeScreen({super.key}); + + @override + State createState() => _ModeratorHomeScreenState(); +} + +class _ModeratorHomeScreenState extends State { + final ModeratorHomeBloc bloc = ModeratorHomeBloc(); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + bloc: bloc, + builder: (context, state) { + return Scaffold( + headers: [ + AppBar( + backgroundColor: AppColors.almostBlack, + leading: [ + IconButton.ghost( + icon: const Icon(Icons.print_rounded, color: AppColors.white), + onPressed: () => GetIt.I().toMachines(), + ), + IconButton.ghost( + icon: const Icon(Icons.edit_rounded, color: AppColors.white), + onPressed: () => GetIt.I().toOptions(), + ), + ], + title: TripleTapDetector( + semanticLabel: 'Header', + semanticHint: 'Triple tap to log out', + onPressed: () => GetIt.I().signOut(), + child: Center( + child: const Text( + 'Moderator Queue', + style: TextStyle(color: AppColors.white), + ).h3, + ), + ), + ), + ], + child: InternalOrderQueues.moderator( + baristas: state.baristas, + orders: state.moderationQueue, + onApproveAll: (orderId) async { + bloc.add(ApproveNameAndImage(orderId)); + await GetIt.I.get().logEvent( + name: 'moderator_approve_all', + ); + }, + onRejectName: (orderId) async { + bloc.add(RejectNameApproveImage(orderId)); + await GetIt.I.get().logEvent( + name: 'moderator_reject_name', + ); + }, + onRejectImage: (orderId) async { + bloc.add(ApproveNameRejectImage(orderId)); + await GetIt.I.get().logEvent( + name: 'moderator_reject_image', + ); + }, + onRejectBoth: (orderId) async { + bloc.add(RejectNameAndImage(orderId)); + await GetIt.I.get().logEvent( + name: 'moderator_reject_both', + ); + }, + onCompletePressed: (orderId) async { + bloc.add(CompleteOrder(orderId)); + await GetIt.I.get().logEvent( + name: 'moderator_complete_order', + ); + }, + ), + ); + }, + ); + } + + @override + Future dispose() async { + await bloc.close(); + super.dispose(); + } +} diff --git a/genlatte/genlatte-ui/lib/src/screens/moderator/machines/CONTEXT.md b/genlatte/genlatte-ui/lib/src/screens/moderator/machines/CONTEXT.md new file mode 100644 index 0000000..559adc3 --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/screens/moderator/machines/CONTEXT.md @@ -0,0 +1,21 @@ +# Moderator Machines + +**Purpose:** +Provides full CRUD (Create, Read, Update, Delete) capability specifically for moderating hardware/printer Machine status in the coffee shop. + +**Detailed File Overviews:** + +- `machines.dart`: + - **Description**: Barrel export file. + +- `machines_bloc.dart`: + - **Description**: State management for machine tracking. + - **Core Logic**: Subscribes to the `Repository` to watch the live list of all physical printers connected to the system. Handles creating new machines via `forceInsert: true` (since IDs act as physical printer identifiers) and exposes toggle logic (`ToggleMachineStatus`) and deletion logic. + +- `machines_view.dart`: + - **Description**: The primary UI interface for the Machines admin portal. + - **Core Logic**: Determines if the viewport implies mobile (`ListView`) vs desktop/tablet (`GridView`) formatting. Loops over the available `machines`, emitting instances of `MachineCard`. Provides a robust creation dialog encompassing fields for ID, Name, and Black/White isolation status, as well as destructive confirmation dialogs. + +**Dependencies/Relationships:** +- Renders `MachineCard` from its `widgets/` subdirectory. +- Accessed by moderators navigating from the `ModeratorHomeScreen`. diff --git a/genlatte/genlatte-ui/lib/src/screens/moderator/machines/machines.dart b/genlatte/genlatte-ui/lib/src/screens/moderator/machines/machines.dart new file mode 100644 index 0000000..6519f49 --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/screens/moderator/machines/machines.dart @@ -0,0 +1,6 @@ +// Copyright 2014 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 'machines_bloc.dart'; +export 'machines_view.dart'; diff --git a/genlatte/genlatte-ui/lib/src/screens/moderator/machines/machines_bloc.dart b/genlatte/genlatte-ui/lib/src/screens/moderator/machines/machines_bloc.dart new file mode 100644 index 0000000..bcb2f68 --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/screens/moderator/machines/machines_bloc.dart @@ -0,0 +1,106 @@ +// Copyright 2014 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:data_layer/data_layer.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:genlatte_data/models.dart'; +import 'package:get_it/get_it.dart'; + +part 'machines_bloc.freezed.dart'; + +typedef _Emit = Emitter; + +/// {@template MachinesBloc} +/// State manager for the Machines CRUD page. +/// {@endtemplate} +class MachinesBloc extends Bloc { + /// {@macro MachinesBloc} + MachinesBloc() : super(MachinesState.initial()) { + on( + (event, _Emit emit) => switch (event) { + NewMachines() => _onNewMachines(event, emit), + ToggleMachineStatus() => _onToggleMachineStatus(event, emit), + CreateMachine() => _onCreateMachine(event, emit), + DeleteMachine() => _onDeleteMachine(event, emit), + }, + ); + + _machinesRepository = GetIt.I>(); + + _machinesStreamSub = _machinesRepository.watchList().listen( + (machines) => add(NewMachines(machines)), + ); + } + + late final Repository _machinesRepository; + + late final StreamSubscription> _machinesStreamSub; + + void _onNewMachines(NewMachines event, _Emit emit) => + emit(state.copyWith(machines: event.machines)); + + Future _onToggleMachineStatus( + ToggleMachineStatus event, + _Emit emit, + ) async { + await _machinesRepository.setItem( + event.machine.copyWith(isActive: !event.machine.isActive), + ); + } + + Future _onCreateMachine(CreateMachine event, _Emit emit) async => + // Ids are set by the physical printers, so the Moderators must supply + // those instead of letting Firestore set typical document Ids. + // Therefore, we must use `forceInsert: true`. + _machinesRepository.setItem( + event.machine, + RequestDetails.write(forceInsert: true), + ); + + Future _onDeleteMachine(DeleteMachine event, _Emit emit) async => + _machinesRepository.delete(event.id); + + @override + Future close() { + _machinesStreamSub.cancel().ignore(); + return super.close(); + } +} + +/// Actions that can be taken on the Machines page. +@Freezed() +sealed class MachinesEvent with _$MachinesEvent { + /// A new list of machines has arrived from the server. + const factory MachinesEvent.newMachines(List machines) = NewMachines; + + /// Toggles the active status of the given machine. + const factory MachinesEvent.toggleMachineStatus( + Machine machine, + ) = ToggleMachineStatus; + + /// Creates a new machine with the given properties. + const factory MachinesEvent.createMachine(Machine machine) = CreateMachine; + + /// Deletes the machine with the given id. + const factory MachinesEvent.deleteMachine(String id) = DeleteMachine; +} + +/// {@template MachinesState} +/// Complete representation of the Machines page's state. +/// {@endtemplate +@Freezed() +sealed class MachinesState with _$MachinesState { + /// {@macro MachinesState} + const factory MachinesState({ + /// All machines available on the server. + @Default([]) List machines, + }) = _MachinesState; + const MachinesState._(); + + /// Starter state fed to the [MachinesBloc]. + factory MachinesState.initial() => const MachinesState(); +} diff --git a/genlatte/genlatte-ui/lib/src/screens/moderator/machines/machines_bloc.freezed.dart b/genlatte/genlatte-ui/lib/src/screens/moderator/machines/machines_bloc.freezed.dart new file mode 100644 index 0000000..ebe40e8 --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/screens/moderator/machines/machines_bloc.freezed.dart @@ -0,0 +1,738 @@ +// Copyright 2014 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. + +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'machines_bloc.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; +/// @nodoc +mixin _$MachinesEvent { + + + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is MachinesEvent); +} + + +@override +int get hashCode => runtimeType.hashCode; + +@override +String toString() { + return 'MachinesEvent()'; +} + + +} + +/// @nodoc +class $MachinesEventCopyWith<$Res> { +$MachinesEventCopyWith(MachinesEvent _, $Res Function(MachinesEvent) __); +} + + +/// Adds pattern-matching-related methods to [MachinesEvent]. +extension MachinesEventPatterns on MachinesEvent { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap({TResult Function( NewMachines value)? newMachines,TResult Function( ToggleMachineStatus value)? toggleMachineStatus,TResult Function( CreateMachine value)? createMachine,TResult Function( DeleteMachine value)? deleteMachine,required TResult orElse(),}){ +final _that = this; +switch (_that) { +case NewMachines() when newMachines != null: +return newMachines(_that);case ToggleMachineStatus() when toggleMachineStatus != null: +return toggleMachineStatus(_that);case CreateMachine() when createMachine != null: +return createMachine(_that);case DeleteMachine() when deleteMachine != null: +return deleteMachine(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map({required TResult Function( NewMachines value) newMachines,required TResult Function( ToggleMachineStatus value) toggleMachineStatus,required TResult Function( CreateMachine value) createMachine,required TResult Function( DeleteMachine value) deleteMachine,}){ +final _that = this; +switch (_that) { +case NewMachines(): +return newMachines(_that);case ToggleMachineStatus(): +return toggleMachineStatus(_that);case CreateMachine(): +return createMachine(_that);case DeleteMachine(): +return deleteMachine(_that);} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull({TResult? Function( NewMachines value)? newMachines,TResult? Function( ToggleMachineStatus value)? toggleMachineStatus,TResult? Function( CreateMachine value)? createMachine,TResult? Function( DeleteMachine value)? deleteMachine,}){ +final _that = this; +switch (_that) { +case NewMachines() when newMachines != null: +return newMachines(_that);case ToggleMachineStatus() when toggleMachineStatus != null: +return toggleMachineStatus(_that);case CreateMachine() when createMachine != null: +return createMachine(_that);case DeleteMachine() when deleteMachine != null: +return deleteMachine(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen({TResult Function( List machines)? newMachines,TResult Function( Machine machine)? toggleMachineStatus,TResult Function( Machine machine)? createMachine,TResult Function( String id)? deleteMachine,required TResult orElse(),}) {final _that = this; +switch (_that) { +case NewMachines() when newMachines != null: +return newMachines(_that.machines);case ToggleMachineStatus() when toggleMachineStatus != null: +return toggleMachineStatus(_that.machine);case CreateMachine() when createMachine != null: +return createMachine(_that.machine);case DeleteMachine() when deleteMachine != null: +return deleteMachine(_that.id);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when({required TResult Function( List machines) newMachines,required TResult Function( Machine machine) toggleMachineStatus,required TResult Function( Machine machine) createMachine,required TResult Function( String id) deleteMachine,}) {final _that = this; +switch (_that) { +case NewMachines(): +return newMachines(_that.machines);case ToggleMachineStatus(): +return toggleMachineStatus(_that.machine);case CreateMachine(): +return createMachine(_that.machine);case DeleteMachine(): +return deleteMachine(_that.id);} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull({TResult? Function( List machines)? newMachines,TResult? Function( Machine machine)? toggleMachineStatus,TResult? Function( Machine machine)? createMachine,TResult? Function( String id)? deleteMachine,}) {final _that = this; +switch (_that) { +case NewMachines() when newMachines != null: +return newMachines(_that.machines);case ToggleMachineStatus() when toggleMachineStatus != null: +return toggleMachineStatus(_that.machine);case CreateMachine() when createMachine != null: +return createMachine(_that.machine);case DeleteMachine() when deleteMachine != null: +return deleteMachine(_that.id);case _: + return null; + +} +} + +} + +/// @nodoc + + +class NewMachines implements MachinesEvent { + const NewMachines(final List machines): _machines = machines; + + + final List _machines; + List get machines { + if (_machines is EqualUnmodifiableListView) return _machines; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_machines); +} + + +/// Create a copy of MachinesEvent +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$NewMachinesCopyWith get copyWith => _$NewMachinesCopyWithImpl(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is NewMachines&&const DeepCollectionEquality().equals(other._machines, _machines)); +} + + +@override +int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(_machines)); + +@override +String toString() { + return 'MachinesEvent.newMachines(machines: $machines)'; +} + + +} + +/// @nodoc +abstract mixin class $NewMachinesCopyWith<$Res> implements $MachinesEventCopyWith<$Res> { + factory $NewMachinesCopyWith(NewMachines value, $Res Function(NewMachines) _then) = _$NewMachinesCopyWithImpl; +@useResult +$Res call({ + List machines +}); + + + + +} +/// @nodoc +class _$NewMachinesCopyWithImpl<$Res> + implements $NewMachinesCopyWith<$Res> { + _$NewMachinesCopyWithImpl(this._self, this._then); + + final NewMachines _self; + final $Res Function(NewMachines) _then; + +/// Create a copy of MachinesEvent +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') $Res call({Object? machines = null,}) { + return _then(NewMachines( +null == machines ? _self._machines : machines // ignore: cast_nullable_to_non_nullable +as List, + )); +} + + +} + +/// @nodoc + + +class ToggleMachineStatus implements MachinesEvent { + const ToggleMachineStatus(this.machine); + + + final Machine machine; + +/// Create a copy of MachinesEvent +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$ToggleMachineStatusCopyWith get copyWith => _$ToggleMachineStatusCopyWithImpl(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is ToggleMachineStatus&&(identical(other.machine, machine) || other.machine == machine)); +} + + +@override +int get hashCode => Object.hash(runtimeType,machine); + +@override +String toString() { + return 'MachinesEvent.toggleMachineStatus(machine: $machine)'; +} + + +} + +/// @nodoc +abstract mixin class $ToggleMachineStatusCopyWith<$Res> implements $MachinesEventCopyWith<$Res> { + factory $ToggleMachineStatusCopyWith(ToggleMachineStatus value, $Res Function(ToggleMachineStatus) _then) = _$ToggleMachineStatusCopyWithImpl; +@useResult +$Res call({ + Machine machine +}); + + +$MachineCopyWith<$Res> get machine; + +} +/// @nodoc +class _$ToggleMachineStatusCopyWithImpl<$Res> + implements $ToggleMachineStatusCopyWith<$Res> { + _$ToggleMachineStatusCopyWithImpl(this._self, this._then); + + final ToggleMachineStatus _self; + final $Res Function(ToggleMachineStatus) _then; + +/// Create a copy of MachinesEvent +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') $Res call({Object? machine = null,}) { + return _then(ToggleMachineStatus( +null == machine ? _self.machine : machine // ignore: cast_nullable_to_non_nullable +as Machine, + )); +} + +/// Create a copy of MachinesEvent +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$MachineCopyWith<$Res> get machine { + + return $MachineCopyWith<$Res>(_self.machine, (value) { + return _then(_self.copyWith(machine: value)); + }); +} +} + +/// @nodoc + + +class CreateMachine implements MachinesEvent { + const CreateMachine(this.machine); + + + final Machine machine; + +/// Create a copy of MachinesEvent +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$CreateMachineCopyWith get copyWith => _$CreateMachineCopyWithImpl(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is CreateMachine&&(identical(other.machine, machine) || other.machine == machine)); +} + + +@override +int get hashCode => Object.hash(runtimeType,machine); + +@override +String toString() { + return 'MachinesEvent.createMachine(machine: $machine)'; +} + + +} + +/// @nodoc +abstract mixin class $CreateMachineCopyWith<$Res> implements $MachinesEventCopyWith<$Res> { + factory $CreateMachineCopyWith(CreateMachine value, $Res Function(CreateMachine) _then) = _$CreateMachineCopyWithImpl; +@useResult +$Res call({ + Machine machine +}); + + +$MachineCopyWith<$Res> get machine; + +} +/// @nodoc +class _$CreateMachineCopyWithImpl<$Res> + implements $CreateMachineCopyWith<$Res> { + _$CreateMachineCopyWithImpl(this._self, this._then); + + final CreateMachine _self; + final $Res Function(CreateMachine) _then; + +/// Create a copy of MachinesEvent +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') $Res call({Object? machine = null,}) { + return _then(CreateMachine( +null == machine ? _self.machine : machine // ignore: cast_nullable_to_non_nullable +as Machine, + )); +} + +/// Create a copy of MachinesEvent +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$MachineCopyWith<$Res> get machine { + + return $MachineCopyWith<$Res>(_self.machine, (value) { + return _then(_self.copyWith(machine: value)); + }); +} +} + +/// @nodoc + + +class DeleteMachine implements MachinesEvent { + const DeleteMachine(this.id); + + + final String id; + +/// Create a copy of MachinesEvent +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$DeleteMachineCopyWith get copyWith => _$DeleteMachineCopyWithImpl(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is DeleteMachine&&(identical(other.id, id) || other.id == id)); +} + + +@override +int get hashCode => Object.hash(runtimeType,id); + +@override +String toString() { + return 'MachinesEvent.deleteMachine(id: $id)'; +} + + +} + +/// @nodoc +abstract mixin class $DeleteMachineCopyWith<$Res> implements $MachinesEventCopyWith<$Res> { + factory $DeleteMachineCopyWith(DeleteMachine value, $Res Function(DeleteMachine) _then) = _$DeleteMachineCopyWithImpl; +@useResult +$Res call({ + String id +}); + + + + +} +/// @nodoc +class _$DeleteMachineCopyWithImpl<$Res> + implements $DeleteMachineCopyWith<$Res> { + _$DeleteMachineCopyWithImpl(this._self, this._then); + + final DeleteMachine _self; + final $Res Function(DeleteMachine) _then; + +/// Create a copy of MachinesEvent +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') $Res call({Object? id = null,}) { + return _then(DeleteMachine( +null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String, + )); +} + + +} + +/// @nodoc +mixin _$MachinesState { + +/// All machines available on the server. + List get machines; +/// Create a copy of MachinesState +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$MachinesStateCopyWith get copyWith => _$MachinesStateCopyWithImpl(this as MachinesState, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is MachinesState&&const DeepCollectionEquality().equals(other.machines, machines)); +} + + +@override +int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(machines)); + +@override +String toString() { + return 'MachinesState(machines: $machines)'; +} + + +} + +/// @nodoc +abstract mixin class $MachinesStateCopyWith<$Res> { + factory $MachinesStateCopyWith(MachinesState value, $Res Function(MachinesState) _then) = _$MachinesStateCopyWithImpl; +@useResult +$Res call({ + List machines +}); + + + + +} +/// @nodoc +class _$MachinesStateCopyWithImpl<$Res> + implements $MachinesStateCopyWith<$Res> { + _$MachinesStateCopyWithImpl(this._self, this._then); + + final MachinesState _self; + final $Res Function(MachinesState) _then; + +/// Create a copy of MachinesState +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? machines = null,}) { + return _then(_self.copyWith( +machines: null == machines ? _self.machines : machines // ignore: cast_nullable_to_non_nullable +as List, + )); +} + +} + + +/// Adds pattern-matching-related methods to [MachinesState]. +extension MachinesStatePatterns on MachinesState { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _MachinesState value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _MachinesState() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _MachinesState value) $default,){ +final _that = this; +switch (_that) { +case _MachinesState(): +return $default(_that);} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _MachinesState value)? $default,){ +final _that = this; +switch (_that) { +case _MachinesState() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( List machines)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _MachinesState() when $default != null: +return $default(_that.machines);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( List machines) $default,) {final _that = this; +switch (_that) { +case _MachinesState(): +return $default(_that.machines);} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( List machines)? $default,) {final _that = this; +switch (_that) { +case _MachinesState() when $default != null: +return $default(_that.machines);case _: + return null; + +} +} + +} + +/// @nodoc + + +class _MachinesState extends MachinesState { + const _MachinesState({final List machines = const []}): _machines = machines,super._(); + + +/// All machines available on the server. + final List _machines; +/// All machines available on the server. +@override@JsonKey() List get machines { + if (_machines is EqualUnmodifiableListView) return _machines; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_machines); +} + + +/// Create a copy of MachinesState +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$MachinesStateCopyWith<_MachinesState> get copyWith => __$MachinesStateCopyWithImpl<_MachinesState>(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _MachinesState&&const DeepCollectionEquality().equals(other._machines, _machines)); +} + + +@override +int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(_machines)); + +@override +String toString() { + return 'MachinesState(machines: $machines)'; +} + + +} + +/// @nodoc +abstract mixin class _$MachinesStateCopyWith<$Res> implements $MachinesStateCopyWith<$Res> { + factory _$MachinesStateCopyWith(_MachinesState value, $Res Function(_MachinesState) _then) = __$MachinesStateCopyWithImpl; +@override @useResult +$Res call({ + List machines +}); + + + + +} +/// @nodoc +class __$MachinesStateCopyWithImpl<$Res> + implements _$MachinesStateCopyWith<$Res> { + __$MachinesStateCopyWithImpl(this._self, this._then); + + final _MachinesState _self; + final $Res Function(_MachinesState) _then; + +/// Create a copy of MachinesState +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? machines = null,}) { + return _then(_MachinesState( +machines: null == machines ? _self._machines : machines // ignore: cast_nullable_to_non_nullable +as List, + )); +} + + +} + +// dart format on diff --git a/genlatte/genlatte-ui/lib/src/screens/moderator/machines/machines_view.dart b/genlatte/genlatte-ui/lib/src/screens/moderator/machines/machines_view.dart new file mode 100644 index 0000000..b91e141 --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/screens/moderator/machines/machines_view.dart @@ -0,0 +1,251 @@ +// Copyright 2014 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:math'; + +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:genlatte/src/core/core.dart'; +import 'package:genlatte/src/screens/app/app.dart'; +import 'package:genlatte/src/screens/moderator/machines/machines.dart'; +import 'package:genlatte/src/screens/moderator/machines/widgets/widgets.dart'; +import 'package:genlatte_data/models.dart'; +import 'package:get_it/get_it.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +/// {@template MachinesScreen} +/// Initial Machines screen. +/// {@endtemplate} +class MachinesScreen extends StatefulWidget { + /// {@macro MachinesScreen} + const MachinesScreen({super.key}); + + @override + State createState() => _MachinesScreenState(); +} + +class _MachinesScreenState extends State { + final MachinesBloc bloc = MachinesBloc(); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + bloc: bloc, + builder: (context, state) { + return Scaffold( + headers: [ + AppBar( + backgroundColor: AppColors.almostBlack, + leading: [ + IconButton.ghost( + icon: const Icon( + Icons.arrow_back_rounded, + color: AppColors.white, + ), + onPressed: () => GetIt.I().router.pop(), + ), + ], + title: Center( + child: const Text( + 'Machines', + style: TextStyle(color: AppColors.white), + ).h3, + ), + trailing: [ + IconButton.ghost( + icon: const Icon(Icons.add_rounded, color: AppColors.white), + onPressed: () => _showCreateMachineDialog(context), + ), + ], + ), + ], + child: LayoutBuilder( + builder: (context, constraints) { + if (state.machines.isEmpty) { + return const Center( + child: Text( + 'No machines available.', + style: TextStyle(color: AppColors.white), + ), + ); + } + + if (constraints.maxWidth > 800) { + return GridView.builder( + padding: const EdgeInsets.all(24), + gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: 400, + crossAxisSpacing: 24, + mainAxisSpacing: 24, + mainAxisExtent: 170, + ), + itemCount: state.machines.length, + itemBuilder: (context, index) { + final machine = state.machines[index]; + return MachineCard( + machine: machine, + toggleStatus: () => + bloc.add(ToggleMachineStatus(machine)), + onDelete: () => _confirmDelete(context, machine), + ); + }, + ); + } + + return ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: state.machines.length, + itemBuilder: (context, index) { + final machine = state.machines[index]; + return Padding( + padding: const EdgeInsets.only(bottom: 16), + child: MachineCard( + machine: machine, + toggleStatus: () => + bloc.add(ToggleMachineStatus(machine)), + onDelete: () => _confirmDelete(context, machine), + ), + ); + }, + ); + }, + ), + ); + }, + ); + } + + void _showCreateMachineDialog(BuildContext context) { + final nameController = TextEditingController(); + final idController = TextEditingController(); + bool isBlackAndWhite = true; + + showDialog( + context: context, + builder: (context) { + return Theme( + data: formsTheme, + child: StatefulBuilder( + builder: (context, setState) { + final size = MediaQuery.sizeOf(context); + return SizedBox( + width: min(640, size.width * 0.8), + child: AlertDialog( + title: const Text('Add New Machine'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Text( + 'Machine Id', + style: TextStyle(color: AppColors.almostBlack), + ).bold, + const SizedBox(height: 8), + TextField( + controller: idController, + placeholder: const Text('Machine Id'), + ), + const SizedBox(height: 24), + const Text( + 'Machine Name', + style: TextStyle(color: AppColors.almostBlack), + ).bold, + const SizedBox(height: 8), + TextField( + controller: nameController, + placeholder: const Text('Machine Name'), + ), + const SizedBox(height: 24), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + 'Black and White Only', + style: TextStyle(color: AppColors.almostBlack), + ).bold, + Switch( + value: isBlackAndWhite, + onChanged: (val) => + setState(() => isBlackAndWhite = val), + ), + ], + ), + ], + ), + actions: [ + SecondaryButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + PrimaryButton( + onPressed: () { + final text = nameController.text.trim(); + final id = idController.text.trim(); + if (text.isNotEmpty) { + bloc.add( + CreateMachine( + Machine( + id: id, + name: text, + isBlackAndWhite: isBlackAndWhite, + ), + ), + ); + Navigator.of(context).pop(); + } + }, + child: const Text('Create'), + ), + ], + ), + ); + }, + ), + ); + }, + ).ignore(); + } + + void _confirmDelete(BuildContext context, Machine machine) { + showDialog( + context: context, + builder: (context) { + final size = MediaQuery.sizeOf(context); + return SizedBox( + width: min(640, size.width * 0.8), + child: AlertDialog( + title: const Text('Confirm Deletion'), + content: Text( + 'Are you sure you want to completely remove the machine ' + '"${machine.name}"?\n\nIf you live to regret this decision, ' + 'you will have to recreate the machine from scratch.', + style: const TextStyle( + color: AppColors.almostBlack, + fontSize: 16, + ), + ), + actions: [ + SecondaryButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + DestructiveButton( + onPressed: () { + bloc.add(DeleteMachine(machine.id)); + Navigator.of(context).pop(); + }, + child: const Text('Delete'), + ), + ], + ), + ); + }, + ).ignore(); + } + + @override + Future dispose() async { + super.dispose(); + await bloc.close(); + } +} diff --git a/genlatte/genlatte-ui/lib/src/screens/moderator/machines/widgets/CONTEXT.md b/genlatte/genlatte-ui/lib/src/screens/moderator/machines/widgets/CONTEXT.md new file mode 100644 index 0000000..a345e34 --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/screens/moderator/machines/widgets/CONTEXT.md @@ -0,0 +1,19 @@ +# Moderator Machines Widgets + +**Purpose:** +Provides common, reusable widgets for the moderator machines management interface. These widgets are responsible for visually rendering machine status and capabilities. + +**Detailed File Overviews:** + +- `machine_card.dart`: + - **Description**: Defines `MachineCard`, a stateful widget that elegantly displays an individual `Machine`'s status and capabilities in an interactive card layout. + - **Core Logic**: Manages hover animations and determines specific badges based on whether the machine is `.isActive` and `!isBlackAndWhite`. Displays status identifiers (Active/Offline) and specific capabilities (Color/B&W). + - **Exposed Methods & Usage**: Receives `ToggleStatus` and `OnDelete` callbacks to emit state changes up to the parent list or bloc. + +- `widgets.dart`: + - **Description**: Barrel file for exporting `machine_card.dart`. + - **Core Logic/Usage**: Simplifies imports for consumers of the machine widgets. + +**Dependencies/Relationships:** +- Interfaces directly with `models.dart` from `genlatte_data` to consume the `Machine` data model. +- Designed to be used by the parent `moderator/machines` screens for rendering lists of printers/machines. diff --git a/genlatte/genlatte-ui/lib/src/screens/moderator/machines/widgets/machine_card.dart b/genlatte/genlatte-ui/lib/src/screens/moderator/machines/widgets/machine_card.dart new file mode 100644 index 0000000..0fe2d54 --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/screens/moderator/machines/widgets/machine_card.dart @@ -0,0 +1,214 @@ +// Copyright 2014 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:genlatte/src/screens/app/app.dart'; +import 'package:genlatte_data/models.dart'; + +/// A card widget for displaying a [Machine]. +class MachineCard extends StatefulWidget { + /// Creates a new [MachineCard]. + const MachineCard({ + required this.machine, + required this.toggleStatus, + required this.onDelete, + super.key, + }); + + /// The [Machine] to display. + final Machine machine; + + /// Callback for when the toggle status button is pressed. + final void Function() toggleStatus; + + /// Callback for when the delete button is pressed. + final void Function() onDelete; + + @override + State createState() => _MachineCardState(); +} + +class _MachineCardState extends State { + bool _isHovered = false; + + @override + Widget build(BuildContext context) { + final isActive = widget.machine.isActive; + final isColor = !widget.machine.isBlackAndWhite; + + return MouseRegion( + onEnter: (_) => setState(() => _isHovered = true), + onExit: (_) => setState(() => _isHovered = false), + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOut, + transform: Matrix4.translationValues(0, _isHovered ? -2.0 : 0.0, 0), + width: 320, + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: const Color(0xFFE0E0E0)), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: _isHovered ? 0.08 : 0.05), + blurRadius: _isHovered ? 16 : 12, + offset: Offset(0, _isHovered ? 6 : 4), + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header: Name and Status + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Text( + widget.machine.name, + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.w600, + color: Color(0xFF1F2937), + ), + ), + ), + _buildStatusBadge(isActive), + ], + ), + const SizedBox(height: 16), + + // Capabilities / Specs + _buildSpecBadge(isColor), + const SizedBox(height: 16), + + // Action Buttons + Row( + children: [ + Expanded( + child: _buildActionButton( + label: isActive ? 'Deactivate' : 'Activate', + textColor: isActive + ? const Color.fromARGB(255, 223, 93, 93) + : const Color(0xFF2563EB), + backgroundColor: isActive + ? AppColors.white + : const Color(0xFFEFF6FF), + borderColor: isActive + ? const Color.fromARGB(255, 255, 202, 202) + : const Color(0xFFBFDBFE), + onPressed: widget.toggleStatus, + ), + ), + const SizedBox(width: 10), + Expanded( + child: _buildActionButton( + label: 'Delete', + textColor: const Color(0xFFDC2626), + backgroundColor: const Color(0xFFFEF2F2), + borderColor: const Color(0xFFFECACA), + onPressed: widget.onDelete, + ), + ), + ], + ), + ], + ), + ), + ); + } + + // --- Helper Widgets for Internal Components --- + + Widget _buildStatusBadge(bool isActive) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: isActive ? const Color(0xFFDCFCE7) : const Color(0xFFFEE2E2), + borderRadius: BorderRadius.circular(9999), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: isActive + ? const Color(0xFF22C55E) + : const Color(0xFFEF4444), + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 6), + Text( + isActive ? 'ACTIVE' : 'OFFLINE', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w700, + letterSpacing: 0.5, + color: isActive + ? const Color(0xFF15803D) + : const Color(0xFFB91C1C), + ), + ), + ], + ), + ); + } + + Widget _buildSpecBadge(bool isColor) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: const Color(0xFFF3F4F6), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + isColor ? '🎨 Color' : '⚫ B&W Only', + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: Color(0xFF4B5563), + ), + ), + ], + ), + ); + } + + Widget _buildActionButton({ + required String label, + required Color textColor, + required Color backgroundColor, + required Color borderColor, + required VoidCallback onPressed, + }) { + return TextButton( + onPressed: onPressed, + style: TextButton.styleFrom( + foregroundColor: textColor, + backgroundColor: backgroundColor, + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: BorderSide(color: borderColor), + ), + ), + child: Text( + label, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + ); + } +} diff --git a/genlatte/genlatte-ui/lib/src/screens/moderator/machines/widgets/widgets.dart b/genlatte/genlatte-ui/lib/src/screens/moderator/machines/widgets/widgets.dart new file mode 100644 index 0000000..9149f2f --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/screens/moderator/machines/widgets/widgets.dart @@ -0,0 +1,5 @@ +// Copyright 2014 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 'machine_card.dart'; diff --git a/genlatte/genlatte-ui/lib/src/screens/moderator/moderator.dart b/genlatte/genlatte-ui/lib/src/screens/moderator/moderator.dart new file mode 100644 index 0000000..172d072 --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/screens/moderator/moderator.dart @@ -0,0 +1,7 @@ +// Copyright 2014 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 'home/moderator_home.dart'; +export 'machines/machines.dart'; +export 'options/options.dart'; diff --git a/genlatte/genlatte-ui/lib/src/screens/moderator/options/options.dart b/genlatte/genlatte-ui/lib/src/screens/moderator/options/options.dart new file mode 100644 index 0000000..d85991d --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/screens/moderator/options/options.dart @@ -0,0 +1,6 @@ +// Copyright 2014 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 'options_bloc.dart'; +export 'options_view.dart'; diff --git a/genlatte/genlatte-ui/lib/src/screens/moderator/options/options_bloc.dart b/genlatte/genlatte-ui/lib/src/screens/moderator/options/options_bloc.dart new file mode 100644 index 0000000..41c94ef --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/screens/moderator/options/options_bloc.dart @@ -0,0 +1,123 @@ +// Copyright 2014 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:data_layer/data_layer.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:genlatte_data/models.dart'; +import 'package:get_it/get_it.dart'; + +part 'options_bloc.freezed.dart'; + +typedef _Emit = Emitter; + +/// {@template OptionsBloc} +/// State manager for the Options CRUD page. +/// {@endtemplate} +class OptionsBloc extends Bloc { + /// {@macro OptionsBloc} + OptionsBloc() : super(OptionsState.initial()) { + on( + (event, _Emit emit) => switch (event) { + NewOptions() => _onNewOptions(event, emit), + CreateOption() => _onCreateOption(event, emit), + ToggleOptionStatus() => _onToggleOptionStatus(event, emit), + DeleteOption() => _onDeleteOption(event, emit), + }, + ); + + _optionsRepository = GetIt.I>(); + + _optionsStreamSub = _optionsRepository.watchList().listen((options) { + add(NewOptions(options)); + }); + } + + late final Repository _optionsRepository; + late final StreamSubscription> _optionsStreamSub; + + void _onNewOptions(NewOptions event, _Emit emit) => + emit(state.copyWith(options: event.options)); + + Future _onCreateOption(CreateOption event, _Emit emit) async { + final newValues = [...event.parent.values, LatteOption(name: event.name)]; + await _optionsRepository.setItem( + event.parent.copyWith(values: newValues), + ); + } + + Future _onToggleOptionStatus( + ToggleOptionStatus event, + _Emit emit, + ) async { + final newValues = List.from(event.parent.values); + final index = newValues.indexOf(event.option); + if (index != -1) { + newValues[index] = event.option.copyWith( + isAvailable: !event.option.isAvailable, + ); + await _optionsRepository.setItem( + event.parent.copyWith(values: newValues), + ); + } + } + + Future _onDeleteOption(DeleteOption event, _Emit emit) async { + final newValues = List.from(event.parent.values) + ..remove(event.option); + await _optionsRepository.setItem( + event.parent.copyWith(values: newValues), + ); + } + + @override + Future close() { + _optionsStreamSub.cancel().ignore(); + return super.close(); + } +} + +/// Actions that can be taken on the Options page. +@Freezed() +sealed class OptionsEvent with _$OptionsEvent { + /// A new list of options has arrived from the server. + const factory OptionsEvent.newOptions(List options) = + NewOptions; + + /// Creates a new [LatteOption] within the given [LatteOptions]. + const factory OptionsEvent.createOption( + LatteOptions parent, + String name, + ) = CreateOption; + + /// Toggles the active status of the given option. + const factory OptionsEvent.toggleOptionStatus( + LatteOptions parent, + LatteOption option, + ) = ToggleOptionStatus; + + /// Deletes the given [LatteOption] from the given [LatteOptions]. + const factory OptionsEvent.deleteOption( + LatteOptions parent, + LatteOption option, + ) = DeleteOption; +} + +/// {@template OptionsState} +/// Complete representation of the Options page's state. +/// {@endtemplate +@Freezed() +sealed class OptionsState with _$OptionsState { + /// {@macro OptionsState} + const factory OptionsState({ + /// All options available on the server. + @Default([]) List options, + }) = _OptionsState; + const OptionsState._(); + + /// Starter state fed to the [OptionsBloc]. + factory OptionsState.initial() => const OptionsState(); +} diff --git a/genlatte/genlatte-ui/lib/src/screens/moderator/options/options_bloc.freezed.dart b/genlatte/genlatte-ui/lib/src/screens/moderator/options/options_bloc.freezed.dart new file mode 100644 index 0000000..68e614f --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/screens/moderator/options/options_bloc.freezed.dart @@ -0,0 +1,771 @@ +// Copyright 2014 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. + +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'options_bloc.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; +/// @nodoc +mixin _$OptionsEvent { + + + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is OptionsEvent); +} + + +@override +int get hashCode => runtimeType.hashCode; + +@override +String toString() { + return 'OptionsEvent()'; +} + + +} + +/// @nodoc +class $OptionsEventCopyWith<$Res> { +$OptionsEventCopyWith(OptionsEvent _, $Res Function(OptionsEvent) __); +} + + +/// Adds pattern-matching-related methods to [OptionsEvent]. +extension OptionsEventPatterns on OptionsEvent { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap({TResult Function( NewOptions value)? newOptions,TResult Function( CreateOption value)? createOption,TResult Function( ToggleOptionStatus value)? toggleOptionStatus,TResult Function( DeleteOption value)? deleteOption,required TResult orElse(),}){ +final _that = this; +switch (_that) { +case NewOptions() when newOptions != null: +return newOptions(_that);case CreateOption() when createOption != null: +return createOption(_that);case ToggleOptionStatus() when toggleOptionStatus != null: +return toggleOptionStatus(_that);case DeleteOption() when deleteOption != null: +return deleteOption(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map({required TResult Function( NewOptions value) newOptions,required TResult Function( CreateOption value) createOption,required TResult Function( ToggleOptionStatus value) toggleOptionStatus,required TResult Function( DeleteOption value) deleteOption,}){ +final _that = this; +switch (_that) { +case NewOptions(): +return newOptions(_that);case CreateOption(): +return createOption(_that);case ToggleOptionStatus(): +return toggleOptionStatus(_that);case DeleteOption(): +return deleteOption(_that);} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull({TResult? Function( NewOptions value)? newOptions,TResult? Function( CreateOption value)? createOption,TResult? Function( ToggleOptionStatus value)? toggleOptionStatus,TResult? Function( DeleteOption value)? deleteOption,}){ +final _that = this; +switch (_that) { +case NewOptions() when newOptions != null: +return newOptions(_that);case CreateOption() when createOption != null: +return createOption(_that);case ToggleOptionStatus() when toggleOptionStatus != null: +return toggleOptionStatus(_that);case DeleteOption() when deleteOption != null: +return deleteOption(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen({TResult Function( List options)? newOptions,TResult Function( LatteOptions parent, String name)? createOption,TResult Function( LatteOptions parent, LatteOption option)? toggleOptionStatus,TResult Function( LatteOptions parent, LatteOption option)? deleteOption,required TResult orElse(),}) {final _that = this; +switch (_that) { +case NewOptions() when newOptions != null: +return newOptions(_that.options);case CreateOption() when createOption != null: +return createOption(_that.parent,_that.name);case ToggleOptionStatus() when toggleOptionStatus != null: +return toggleOptionStatus(_that.parent,_that.option);case DeleteOption() when deleteOption != null: +return deleteOption(_that.parent,_that.option);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when({required TResult Function( List options) newOptions,required TResult Function( LatteOptions parent, String name) createOption,required TResult Function( LatteOptions parent, LatteOption option) toggleOptionStatus,required TResult Function( LatteOptions parent, LatteOption option) deleteOption,}) {final _that = this; +switch (_that) { +case NewOptions(): +return newOptions(_that.options);case CreateOption(): +return createOption(_that.parent,_that.name);case ToggleOptionStatus(): +return toggleOptionStatus(_that.parent,_that.option);case DeleteOption(): +return deleteOption(_that.parent,_that.option);} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull({TResult? Function( List options)? newOptions,TResult? Function( LatteOptions parent, String name)? createOption,TResult? Function( LatteOptions parent, LatteOption option)? toggleOptionStatus,TResult? Function( LatteOptions parent, LatteOption option)? deleteOption,}) {final _that = this; +switch (_that) { +case NewOptions() when newOptions != null: +return newOptions(_that.options);case CreateOption() when createOption != null: +return createOption(_that.parent,_that.name);case ToggleOptionStatus() when toggleOptionStatus != null: +return toggleOptionStatus(_that.parent,_that.option);case DeleteOption() when deleteOption != null: +return deleteOption(_that.parent,_that.option);case _: + return null; + +} +} + +} + +/// @nodoc + + +class NewOptions implements OptionsEvent { + const NewOptions(final List options): _options = options; + + + final List _options; + List get options { + if (_options is EqualUnmodifiableListView) return _options; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_options); +} + + +/// Create a copy of OptionsEvent +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$NewOptionsCopyWith get copyWith => _$NewOptionsCopyWithImpl(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is NewOptions&&const DeepCollectionEquality().equals(other._options, _options)); +} + + +@override +int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(_options)); + +@override +String toString() { + return 'OptionsEvent.newOptions(options: $options)'; +} + + +} + +/// @nodoc +abstract mixin class $NewOptionsCopyWith<$Res> implements $OptionsEventCopyWith<$Res> { + factory $NewOptionsCopyWith(NewOptions value, $Res Function(NewOptions) _then) = _$NewOptionsCopyWithImpl; +@useResult +$Res call({ + List options +}); + + + + +} +/// @nodoc +class _$NewOptionsCopyWithImpl<$Res> + implements $NewOptionsCopyWith<$Res> { + _$NewOptionsCopyWithImpl(this._self, this._then); + + final NewOptions _self; + final $Res Function(NewOptions) _then; + +/// Create a copy of OptionsEvent +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') $Res call({Object? options = null,}) { + return _then(NewOptions( +null == options ? _self._options : options // ignore: cast_nullable_to_non_nullable +as List, + )); +} + + +} + +/// @nodoc + + +class CreateOption implements OptionsEvent { + const CreateOption(this.parent, this.name); + + + final LatteOptions parent; + final String name; + +/// Create a copy of OptionsEvent +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$CreateOptionCopyWith get copyWith => _$CreateOptionCopyWithImpl(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is CreateOption&&(identical(other.parent, parent) || other.parent == parent)&&(identical(other.name, name) || other.name == name)); +} + + +@override +int get hashCode => Object.hash(runtimeType,parent,name); + +@override +String toString() { + return 'OptionsEvent.createOption(parent: $parent, name: $name)'; +} + + +} + +/// @nodoc +abstract mixin class $CreateOptionCopyWith<$Res> implements $OptionsEventCopyWith<$Res> { + factory $CreateOptionCopyWith(CreateOption value, $Res Function(CreateOption) _then) = _$CreateOptionCopyWithImpl; +@useResult +$Res call({ + LatteOptions parent, String name +}); + + +$LatteOptionsCopyWith<$Res> get parent; + +} +/// @nodoc +class _$CreateOptionCopyWithImpl<$Res> + implements $CreateOptionCopyWith<$Res> { + _$CreateOptionCopyWithImpl(this._self, this._then); + + final CreateOption _self; + final $Res Function(CreateOption) _then; + +/// Create a copy of OptionsEvent +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') $Res call({Object? parent = null,Object? name = null,}) { + return _then(CreateOption( +null == parent ? _self.parent : parent // ignore: cast_nullable_to_non_nullable +as LatteOptions,null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable +as String, + )); +} + +/// Create a copy of OptionsEvent +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$LatteOptionsCopyWith<$Res> get parent { + + return $LatteOptionsCopyWith<$Res>(_self.parent, (value) { + return _then(_self.copyWith(parent: value)); + }); +} +} + +/// @nodoc + + +class ToggleOptionStatus implements OptionsEvent { + const ToggleOptionStatus(this.parent, this.option); + + + final LatteOptions parent; + final LatteOption option; + +/// Create a copy of OptionsEvent +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$ToggleOptionStatusCopyWith get copyWith => _$ToggleOptionStatusCopyWithImpl(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is ToggleOptionStatus&&(identical(other.parent, parent) || other.parent == parent)&&(identical(other.option, option) || other.option == option)); +} + + +@override +int get hashCode => Object.hash(runtimeType,parent,option); + +@override +String toString() { + return 'OptionsEvent.toggleOptionStatus(parent: $parent, option: $option)'; +} + + +} + +/// @nodoc +abstract mixin class $ToggleOptionStatusCopyWith<$Res> implements $OptionsEventCopyWith<$Res> { + factory $ToggleOptionStatusCopyWith(ToggleOptionStatus value, $Res Function(ToggleOptionStatus) _then) = _$ToggleOptionStatusCopyWithImpl; +@useResult +$Res call({ + LatteOptions parent, LatteOption option +}); + + +$LatteOptionsCopyWith<$Res> get parent;$LatteOptionCopyWith<$Res> get option; + +} +/// @nodoc +class _$ToggleOptionStatusCopyWithImpl<$Res> + implements $ToggleOptionStatusCopyWith<$Res> { + _$ToggleOptionStatusCopyWithImpl(this._self, this._then); + + final ToggleOptionStatus _self; + final $Res Function(ToggleOptionStatus) _then; + +/// Create a copy of OptionsEvent +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') $Res call({Object? parent = null,Object? option = null,}) { + return _then(ToggleOptionStatus( +null == parent ? _self.parent : parent // ignore: cast_nullable_to_non_nullable +as LatteOptions,null == option ? _self.option : option // ignore: cast_nullable_to_non_nullable +as LatteOption, + )); +} + +/// Create a copy of OptionsEvent +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$LatteOptionsCopyWith<$Res> get parent { + + return $LatteOptionsCopyWith<$Res>(_self.parent, (value) { + return _then(_self.copyWith(parent: value)); + }); +}/// Create a copy of OptionsEvent +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$LatteOptionCopyWith<$Res> get option { + + return $LatteOptionCopyWith<$Res>(_self.option, (value) { + return _then(_self.copyWith(option: value)); + }); +} +} + +/// @nodoc + + +class DeleteOption implements OptionsEvent { + const DeleteOption(this.parent, this.option); + + + final LatteOptions parent; + final LatteOption option; + +/// Create a copy of OptionsEvent +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$DeleteOptionCopyWith get copyWith => _$DeleteOptionCopyWithImpl(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is DeleteOption&&(identical(other.parent, parent) || other.parent == parent)&&(identical(other.option, option) || other.option == option)); +} + + +@override +int get hashCode => Object.hash(runtimeType,parent,option); + +@override +String toString() { + return 'OptionsEvent.deleteOption(parent: $parent, option: $option)'; +} + + +} + +/// @nodoc +abstract mixin class $DeleteOptionCopyWith<$Res> implements $OptionsEventCopyWith<$Res> { + factory $DeleteOptionCopyWith(DeleteOption value, $Res Function(DeleteOption) _then) = _$DeleteOptionCopyWithImpl; +@useResult +$Res call({ + LatteOptions parent, LatteOption option +}); + + +$LatteOptionsCopyWith<$Res> get parent;$LatteOptionCopyWith<$Res> get option; + +} +/// @nodoc +class _$DeleteOptionCopyWithImpl<$Res> + implements $DeleteOptionCopyWith<$Res> { + _$DeleteOptionCopyWithImpl(this._self, this._then); + + final DeleteOption _self; + final $Res Function(DeleteOption) _then; + +/// Create a copy of OptionsEvent +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') $Res call({Object? parent = null,Object? option = null,}) { + return _then(DeleteOption( +null == parent ? _self.parent : parent // ignore: cast_nullable_to_non_nullable +as LatteOptions,null == option ? _self.option : option // ignore: cast_nullable_to_non_nullable +as LatteOption, + )); +} + +/// Create a copy of OptionsEvent +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$LatteOptionsCopyWith<$Res> get parent { + + return $LatteOptionsCopyWith<$Res>(_self.parent, (value) { + return _then(_self.copyWith(parent: value)); + }); +}/// Create a copy of OptionsEvent +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$LatteOptionCopyWith<$Res> get option { + + return $LatteOptionCopyWith<$Res>(_self.option, (value) { + return _then(_self.copyWith(option: value)); + }); +} +} + +/// @nodoc +mixin _$OptionsState { + +/// All options available on the server. + List get options; +/// Create a copy of OptionsState +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$OptionsStateCopyWith get copyWith => _$OptionsStateCopyWithImpl(this as OptionsState, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is OptionsState&&const DeepCollectionEquality().equals(other.options, options)); +} + + +@override +int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(options)); + +@override +String toString() { + return 'OptionsState(options: $options)'; +} + + +} + +/// @nodoc +abstract mixin class $OptionsStateCopyWith<$Res> { + factory $OptionsStateCopyWith(OptionsState value, $Res Function(OptionsState) _then) = _$OptionsStateCopyWithImpl; +@useResult +$Res call({ + List options +}); + + + + +} +/// @nodoc +class _$OptionsStateCopyWithImpl<$Res> + implements $OptionsStateCopyWith<$Res> { + _$OptionsStateCopyWithImpl(this._self, this._then); + + final OptionsState _self; + final $Res Function(OptionsState) _then; + +/// Create a copy of OptionsState +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? options = null,}) { + return _then(_self.copyWith( +options: null == options ? _self.options : options // ignore: cast_nullable_to_non_nullable +as List, + )); +} + +} + + +/// Adds pattern-matching-related methods to [OptionsState]. +extension OptionsStatePatterns on OptionsState { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _OptionsState value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _OptionsState() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _OptionsState value) $default,){ +final _that = this; +switch (_that) { +case _OptionsState(): +return $default(_that);} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _OptionsState value)? $default,){ +final _that = this; +switch (_that) { +case _OptionsState() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( List options)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _OptionsState() when $default != null: +return $default(_that.options);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( List options) $default,) {final _that = this; +switch (_that) { +case _OptionsState(): +return $default(_that.options);} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( List options)? $default,) {final _that = this; +switch (_that) { +case _OptionsState() when $default != null: +return $default(_that.options);case _: + return null; + +} +} + +} + +/// @nodoc + + +class _OptionsState extends OptionsState { + const _OptionsState({final List options = const []}): _options = options,super._(); + + +/// All options available on the server. + final List _options; +/// All options available on the server. +@override@JsonKey() List get options { + if (_options is EqualUnmodifiableListView) return _options; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_options); +} + + +/// Create a copy of OptionsState +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$OptionsStateCopyWith<_OptionsState> get copyWith => __$OptionsStateCopyWithImpl<_OptionsState>(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _OptionsState&&const DeepCollectionEquality().equals(other._options, _options)); +} + + +@override +int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(_options)); + +@override +String toString() { + return 'OptionsState(options: $options)'; +} + + +} + +/// @nodoc +abstract mixin class _$OptionsStateCopyWith<$Res> implements $OptionsStateCopyWith<$Res> { + factory _$OptionsStateCopyWith(_OptionsState value, $Res Function(_OptionsState) _then) = __$OptionsStateCopyWithImpl; +@override @useResult +$Res call({ + List options +}); + + + + +} +/// @nodoc +class __$OptionsStateCopyWithImpl<$Res> + implements _$OptionsStateCopyWith<$Res> { + __$OptionsStateCopyWithImpl(this._self, this._then); + + final _OptionsState _self; + final $Res Function(_OptionsState) _then; + +/// Create a copy of OptionsState +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? options = null,}) { + return _then(_OptionsState( +options: null == options ? _self._options : options // ignore: cast_nullable_to_non_nullable +as List, + )); +} + + +} + +// dart format on diff --git a/genlatte/genlatte-ui/lib/src/screens/moderator/options/options_view.dart b/genlatte/genlatte-ui/lib/src/screens/moderator/options/options_view.dart new file mode 100644 index 0000000..418ffa5 --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/screens/moderator/options/options_view.dart @@ -0,0 +1,248 @@ +// Copyright 2014 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:math'; + +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:genlatte/src/core/core.dart'; +import 'package:genlatte/src/screens/app/app.dart'; +import 'package:genlatte/src/screens/moderator/options/options.dart'; +import 'package:genlatte_data/models.dart'; +import 'package:get_it/get_it.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +/// {@template OptionsScreen} +/// Initial Options screen. +/// {@endtemplate} +class OptionsScreen extends StatefulWidget { + /// {@macro OptionsScreen} + const OptionsScreen({super.key}); + + @override + State createState() => _OptionsScreenState(); +} + +class _OptionsScreenState extends State { + final OptionsBloc bloc = OptionsBloc(); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + bloc: bloc, + builder: (context, state) { + return Scaffold( + headers: [ + AppBar( + backgroundColor: AppColors.almostBlack, + leading: [ + IconButton.ghost( + icon: const Icon( + Icons.arrow_back_rounded, + color: AppColors.white, + ), + onPressed: () => GetIt.I().router.pop(), + ), + ], + title: Center( + child: const Text( + 'Options', + style: TextStyle(color: AppColors.white), + ).h3, + ), + ), + ], + child: LayoutBuilder( + builder: (context, constraints) { + if (state.options.isEmpty) { + return const Center( + child: Text( + 'No options available.', + style: TextStyle(color: AppColors.white), + ), + ); + } + + return ListView.builder( + padding: const EdgeInsets.all(24), + itemCount: state.options.length, + itemBuilder: (context, index) { + final group = state.options[index]; + return Padding( + padding: EdgeInsets.symmetric( + horizontal: constraints.maxWidth * 0.1, + vertical: 8, + ), + child: Card( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(group.id.capitalize()).h4, + IconButton.ghost( + icon: const Icon(Icons.add_rounded), + onPressed: () => _showCreateOptionDialog( + context, + group, + ), + ), + ], + ), + const SizedBox(height: 16), + if (group.values.isEmpty) + const Text('No options in this group.'), + for (final option in group.values) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + mainAxisAlignment: + MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text(option.name), + ), + Switch( + value: option.isAvailable, + onChanged: (val) => bloc.add( + ToggleOptionStatus(group, option), + ), + ), + IconButton.ghost( + icon: const Icon( + Icons.delete_rounded, + color: Colors.red, + ), + onPressed: () => _confirmDelete( + context, + group, + option, + ), + ), + ], + ), + ), + ], + ), + ), + ); + }, + ); + }, + ), + ); + }, + ); + } + + void _showCreateOptionDialog(BuildContext context, LatteOptions group) { + final nameController = TextEditingController(); + + showDialog( + context: context, + builder: (context) { + return Theme( + data: formsTheme, + child: StatefulBuilder( + builder: (context, setState) { + final size = MediaQuery.sizeOf(context); + return SizedBox( + width: min(640, size.width * 0.8), + child: AlertDialog( + title: Text('Add Option to ${group.id}'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Text( + 'Option Name', + style: TextStyle(color: AppColors.almostBlack), + ).bold, + const SizedBox(height: 8), + TextField( + controller: nameController, + placeholder: const Text('Option Name'), + ), + ], + ), + actions: [ + SecondaryButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + PrimaryButton( + onPressed: () { + final text = nameController.text.trim(); + if (text.isNotEmpty) { + bloc.add(CreateOption(group, text)); + Navigator.of(context).pop(); + } + }, + child: const Text('Create'), + ), + ], + ), + ); + }, + ), + ); + }, + ).ignore(); + } + + void _confirmDelete( + BuildContext context, + LatteOptions group, + LatteOption option, + ) { + showDialog( + context: context, + builder: (context) { + final size = MediaQuery.sizeOf(context); + return SizedBox( + width: min(640, size.width * 0.8), + child: AlertDialog( + title: const Text('Confirm Deletion'), + content: Text( + 'Are you sure you want to completely remove the option ' + '"${option.name}" from ${group.id}?', + style: const TextStyle( + color: AppColors.almostBlack, + fontSize: 16, + ), + ), + actions: [ + SecondaryButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + DestructiveButton( + onPressed: () { + bloc.add(DeleteOption(group, option)); + Navigator.of(context).pop(); + }, + child: const Text('Delete'), + ), + ], + ), + ); + }, + ).ignore(); + } + + @override + Future dispose() async { + super.dispose(); + await bloc.close(); + } +} + +extension on String { + /// Capitalizes the first letter of the string. + String capitalize() { + if (isEmpty) return this; + return '${this[0].toUpperCase()}${substring(1)}'; + } +} diff --git a/genlatte/genlatte-ui/lib/src/screens/queue/CONTEXT.md b/genlatte/genlatte-ui/lib/src/screens/queue/CONTEXT.md new file mode 100644 index 0000000..04df99c --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/screens/queue/CONTEXT.md @@ -0,0 +1,11 @@ +# Kiosk / Barista / Moderator / Queue / Recent Orders Roots + +**Purpose:** +This directory acts as the architectural boundary grouping for the application's distinct personas. + +**Implementation Details:** +- **Barrel Architecture**: This level strictly exports child module directories like `home/` and `widgets/` via an `index` / `barrel` file. +- **Child Contexts**: Please navigate into `home/` or `widgets/` subdirectories to view specific BLoC architectures, View layouts, and complex logic files tailored towards each app persona. + +**Dependencies:** +- Consumed by `core/routing/routes.dart` to map physical URLs to specific screen entry points. diff --git a/genlatte/genlatte-ui/lib/src/screens/queue/home/CONTEXT.md b/genlatte/genlatte-ui/lib/src/screens/queue/home/CONTEXT.md new file mode 100644 index 0000000..1d6c228 --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/screens/queue/home/CONTEXT.md @@ -0,0 +1,29 @@ +# Queue Home + +**Purpose:** +Hosts the large-format, passive "Queue Board" screen used to alert consumers to the status of their submitted drinks. This application is designed to be completely autonomous, reacting dynamically to distributed databases, managing paging seamlessly across varying hardware deployments without centralized communication. + +**Detailed File Overviews:** + +- `queue_home.dart`: + - **Description**: Barrel export file. + +- `queue_home_bloc.dart`: + - **Description**: Core autonomous state management for the display board. + - **Core Logic**: Extremely complex, managing both active state subscriptions and local persisted UI settings (Page Duration, Row Heights). + - Implements a pure client-side Sharding Strategy (`_doesOrderBelongToShard`) based on `pkg:hashlib` `xxh32code`. This enables multiple queue display screens to filter requests randomly but deterministically locally without direct horizontal synchronization. + - Manages pagination computations (`_emitUpdatedPages`) dynamically re-calculating batch slots based on available spatial capacity. + +- `queue_home_view.dart`: + - **Description**: Scaffold implementation encapsulating the `OrderList`. + - **Core Logic**: Wraps the Queue Board inside a visual `_Marginalia` container. Implements a highly obfuscated UI hook (100x100 `MouseRegion` hit area in the top right corner) designed to secretly expose a `OrderBoardDebugOverlay` used for field administration of shards/timing configurations. + +- `fake_repositories.dart`: + - **Description**: Exposes `FakeLatteOrdersRepository` testing mocks allowing offline simulation. + +- `queue_settings.dart` / `.freezed.dart` / `.g.dart`: + - **Description**: A serializable (JSON via Freezed) model encapsulating tuning preferences for the local board (Shard Total, Shard Target, Update Frequency, Row Height) stored locally into `SharedPreferences`. + +**Dependencies/Relationships:** +- Strongly relies on `queue/widgets` like `OrderList` and `OrderBoardDebugOverlay` for component rendering. +- Subscribes via `LatteOrderMetadataBoardQueue` filter to actively listen to Firebase. diff --git a/genlatte/genlatte-ui/lib/src/screens/queue/home/fake_repositories.dart b/genlatte/genlatte-ui/lib/src/screens/queue/home/fake_repositories.dart new file mode 100644 index 0000000..296ce51 --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/screens/queue/home/fake_repositories.dart @@ -0,0 +1,288 @@ +// Copyright 2014 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:data_layer/data_layer.dart'; +import 'package:genlatte/src/data/data.dart'; +import 'package:genlatte_data/models.dart'; +import 'package:logging/logging.dart'; + +/// Fake repository for testing purposes. +class FakeLatteOrdersMetadataRepository extends Repository { + /// Creates a new instance of [FakeLatteOrdersMetadataRepository]. + FakeLatteOrdersMetadataRepository() + : super( + SourceList( + bindings: LatteOrderMetadata.bindings, + sources: >[ + FakeSource( + bindings: LatteOrderMetadata.bindings, + ), + ], + ), + ); +} + +/// Fake repository for testing purposes. +class FakeLatteOrdersRepository extends Repository + implements LatteOrdersRepository { + /// Creates a new instance of [FakeLatteOrdersRepository]. + FakeLatteOrdersRepository({ + AcceptImage acceptImage = _defaultAcceptImage, + CompleteOrder completeOrder = _defaultCompleteOrder, + GenerateRevisedImages generateRevisedImages = _defaultGenerateRevisedImages, + RejectImageBatch rejectImageBatch = _defaultRejectImageBatch, + SendToPrinters sendToPrinters = _defaultSendToPrinters, + SubmitOrder submitOrder = _defaultSubmitOrder, + }) : _acceptImage = acceptImage, + _completeOrder = completeOrder, + _generateRevisedImages = generateRevisedImages, + _rejectImageBatch = rejectImageBatch, + _sendToPrinters = sendToPrinters, + _submitOrder = submitOrder, + super( + SourceList( + bindings: LatteOrder.bindings, + sources: >[ + FakeSource( + bindings: LatteOrder.bindings, + ), + ], + ), + ); + + static final Logger _logger = Logger('$FakeLatteOrdersRepository'); + + @override + Future acceptImage({ + required String imageBatchId, + required String imageIndex, + }) => _acceptImage(imageBatchId: imageBatchId, imageIndex: imageIndex); + final AcceptImage _acceptImage; + + @override + Future completeOrder({ + required String orderId, + required String baristaId, + }) => _completeOrder(orderId: orderId, baristaId: baristaId); + final CompleteOrder _completeOrder; + + @override + Future generateRevisedImages({ + required String imageBatchId, + required String imageIndex, + required Map answers, + }) => _generateRevisedImages( + imageBatchId: imageBatchId, + imageIndex: imageIndex, + answers: answers, + ); + final GenerateRevisedImages _generateRevisedImages; + + @override + Future rejectImageBatch(String imageBatchId) => + _rejectImageBatch(imageBatchId); + final RejectImageBatch _rejectImageBatch; + + @override + Future sendToPrinters({ + required String imagePath, + required String machineName, + }) => _sendToPrinters(imagePath: imagePath, machineName: machineName); + final SendToPrinters _sendToPrinters; + + @override + Future submitOrder(String orderId) => _submitOrder(orderId); + final SubmitOrder _submitOrder; + + /// Duplicated code from [LatteOrdersRepository]. + @override + Future> toLattes(List metadatas) async { + final metadataMap = metadatas.toIdsMap(); + + final (orders, missingIds) = await getByIds( + metadataMap.keys.toSet(), + // [RequestType.global] (the default value) is fine for this because + // orders no longer change once they are on the Barista's screen; so + // fetching any locally cached records is guaranteed to be fine. + ); + + if (missingIds.isNotEmpty) { + throw DataException( + 'LatteMetaData Ids $missingIds lacked corresponding LatteOrders', + ); + } + + final lattes = []; + + // It is important to loop over [orders] as returned by [getByIds] and not + // over [event.metadatas] because any records lost (as represented by + // [missingIds]) will naturally be omitted in this way. This allows us to + // guarantee that [metadataMap[order.id!]] below will not be null. + for (final order in orders) { + final metadata = metadataMap[order.id!]!; + lattes.add(Latte(order: order, metadata: metadata)); + } + + return lattes; + } + + static Future _defaultAcceptImage({ + required String imageBatchId, + required String imageIndex, + }) async { + _logger.info('acceptImage: $imageBatchId - $imageIndex'); + } + + static Future _defaultCompleteOrder({ + required String orderId, + required String baristaId, + }) async { + _logger.info('completeOrder: $orderId - $baristaId'); + } + + static Future _defaultGenerateRevisedImages({ + required String imageBatchId, + required String imageIndex, + required Map answers, + }) async { + _logger.info( + 'generateRevisedImages: $imageBatchId - $imageIndex - $answers', + ); + } + + static Future _defaultSendToPrinters({ + required String imagePath, + required String machineName, + }) async { + _logger.info('sendToPrinters: $imagePath, $machineName'); + } + + static Future _defaultRejectImageBatch(String imageBatchId) async { + _logger.info('rejectImageBatch: $imageBatchId'); + } + + static Future _defaultSubmitOrder(String orderId) async { + _logger.info('submitOrder: $orderId'); + } +} + +/// Fake source for testing purposes. +class FakeSource extends Source with WatchableSource { + /// Creates a new instance of [FakeSource]. + FakeSource({required super.bindings}); + + int _id = 1; + + final StreamController _updateStream = StreamController.broadcast(); + + final Map _store = {}; + + @override + SourceType get sourceType => SourceType.local; + + @override + Future> delete(DeleteOperation operation) async { + _store.remove(operation.itemId); + _updateStream.add(null); + return DeleteResult.success(operation.details); + } + + @override + Future> getById(ReadOperation operation) async { + final item = _store[operation.itemId]; + return ReadResult.success(item, details: operation.details); + } + + @override + Future> getByIds(ReadByIdsOperation operation) async { + final items = operation.itemIds + .map((id) => _store[id]) + .whereType() + .toList(); + return ReadListResult.fromList( + items, + operation.details, + {}, // TODO(filiph): actually find missing ids. + bindings.getId, + ); + } + + @override + Future> getItems(ReadListOperation operation) async { + final items = _store.values.toList(); + return ReadListResult.fromList( + items, + operation.details, + {}, // TODO(filiph): actually find missing ids. + bindings.getId, + ); + } + + @override + Future> setItem(WriteOperation operation) async { + final existingId = bindings.getId(operation.item); + final id = existingId ?? '${_id++}'; + _store[id] = operation.item; + _updateStream.add(null); + return WriteResult.success(operation.item, details: operation.details); + } + + @override + Future> setItems(WriteListOperation operation) async { + for (final item in operation.items) { + final existingId = bindings.getId(item); + final id = existingId ?? '${_id++}'; + _store[id] = item; + } + _updateStream.add(null); + return WriteListResult.success(operation.items, details: operation.details); + } + + @override + Stream> watch(ReadOperation operation) async* { + ReadResult current = await getById(operation); + yield current; + await for (final _ in _updateStream.stream) { + final next = await getById(operation); + if (next.mapOrNull(success: (s) => s.item) != + current.mapOrNull(success: (s) => s.item)) { + yield next; + current = next; + } + } + } + + @override + Stream> watchByIds(ReadByIdsOperation operation) async* { + ReadListResult current = await getByIds(operation); + yield current; + await for (final _ in _updateStream.stream) { + final next = await getByIds(operation); + if (next.mapOrNull((s) => s.items) != current.mapOrNull((s) => s.items)) { + yield next; + current = next; + } + } + } + + @override + Stream> watchList(ReadListOperation operation) async* { + ReadListResult current = await getItems(operation); + yield current; + await for (final _ in _updateStream.stream) { + final next = await getItems(operation); + if (next.mapOrNull((s) => s.items) != current.mapOrNull((s) => s.items)) { + yield next; + current = next; + } + } + } + + /// Disposes of the [FakeSource]. + void dispose() { + _updateStream.close().ignore(); + } +} diff --git a/genlatte/genlatte-ui/lib/src/screens/queue/home/queue_home.dart b/genlatte/genlatte-ui/lib/src/screens/queue/home/queue_home.dart new file mode 100644 index 0000000..4fdb0dd --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/screens/queue/home/queue_home.dart @@ -0,0 +1,6 @@ +// Copyright 2014 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 'queue_home_bloc.dart'; +export 'queue_home_view.dart'; diff --git a/genlatte/genlatte-ui/lib/src/screens/queue/home/queue_home_bloc.dart b/genlatte/genlatte-ui/lib/src/screens/queue/home/queue_home_bloc.dart new file mode 100644 index 0000000..f3102a4 --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/screens/queue/home/queue_home_bloc.dart @@ -0,0 +1,518 @@ +// Copyright 2014 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:collection'; +import 'dart:convert'; + +import 'package:data_layer/data_layer.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:genlatte/src/data/data.dart'; +import 'package:genlatte/src/data/shared_preferences_repository.dart'; +import 'package:genlatte/src/screens/queue/home/queue_settings.dart'; +import 'package:genlatte/src/screens/queue/util/metadata_describe.dart'; +import 'package:genlatte_data/models.dart'; +import 'package:hashlib/hashlib.dart'; +import 'package:logging/logging.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +part 'queue_home_bloc.freezed.dart'; + +final _logger = Logger('QueueHomeBloc'); + +typedef _Emit = Emitter; + +/// A page of orders. +final class OrderPage { + /// Creates a new [OrderPage]. + const OrderPage(this.orders); + + /// The orders to show on this page. + final List orders; +} + +/// {@template QueueHomeBloc} +/// {@endtemplate} +class QueueHomeBloc extends Bloc { + /// {@macro QueueHomeBloc} + QueueHomeBloc({ + required this.metadataRepository, + required this.ordersRepository, + required this.sharedPrefsRepository, + @visibleForTesting QueueSettingsState? initialSettings, + }) : super( + QueueHomeState( + settings: + initialSettings ?? + QueueSettingsState.load(sharedPrefsRepository), + ), + ) { + on( + (event, _Emit emit) => switch (event) { + OrdersUpdated() => _updateOrdersFromRepository(event, emit), + SetPageUpdatePeriod() => _saveNewPageUpdatePeriod(event, emit), + SetRecency() => _saveNewRecencySetting(event, emit), + SetShard() => _saveNewShardSetting(event, emit), + SetTargetRowHeight() => _emitNewTargetRowHeight(event, emit), + SetTopSettings() => _saveNewTopSetting(event, emit), + SlotsCounted() => _saveNewSlotsCount(event, emit), + RefreshPages() => _emitUpdatedPages(event, emit), + }, + ); + + _logger.info( + 'Creating a new $QueueHomeBloc ' + 'with shard ${state.settings} and $metadataRepository', + ); + + _ordersStreamSub = metadataRepository + .watchList( + details: RequestDetails.read( + // TODO(filiph): Maybe provide sharding information? + filter: const LatteOrderMetadataBoardQueue(), + ), + ) + .listen( + (metadata) => add(OrdersUpdated(metadata)), + onError: (Object e) => _logger.severe('Error when listening: $e', e), + ); + } + + /// Orders that were submitted way too long ago and never completed + /// should not be shown. + static const Duration _abandonedOrderAgeThreshold = Duration(days: 2); + + /// A set of orders (deduplicated on `id`, see [_createOrdersSet]) + /// that have been shown so far. + /// + /// This intentionally keeps orders that are no longer in the repository + /// because we might still need their data to fade them away. + HashSet _allOrders = _createOrdersSet(const []); + + /// The provided orders repository. + final LatteOrdersRepository ordersRepository; + + /// The provided metadata repository. + final Repository metadataRepository; + + /// The provided shared preferences repository. + final SharedPreferencesRepository sharedPrefsRepository; + + late final StreamSubscription> _ordersStreamSub; + + @override + Future close() async { + _ordersStreamSub.cancel().ignore(); + return super.close(); + } + + int _compareOrders(Latte a, Latte b) { + // First, sort by "status progress" (e.g., completed orders go before + // in-progress ones). + int roughPriority(Latte latte) => switch (latte.metadata.status) { + LatteOrderStatus.configuring => 100, + LatteOrderStatus.submitted => 10, + LatteOrderStatus.validated => 1, + LatteOrderStatus.inProgress => -1, + LatteOrderStatus.completed => -10, + LatteOrderStatus.archived => -100, + }; + + final aPriority = roughPriority(a); + final bPriority = roughPriority(b); + if (aPriority < bPriority) return -1; + if (aPriority > bPriority) return 1; + + final aSubmitted = a.metadata.orderSubmittedTime ?? DateTime(1970); + final bSubmitted = b.metadata.orderSubmittedTime ?? DateTime(1970); + return aSubmitted.compareTo(bSubmitted); + } + + void _emitNewTargetRowHeight(SetTargetRowHeight event, _Emit emit) { + _logger.info('_emitNewTargetRowHeight: $event'); + if (event.height < 1) { + _logger.warning('Invalid row height: ${event.height}'); + return; + } + + final newSettings = state.settings.copyWith( + targetRowHeight: event.height, + ); + _persistSettings(newSettings); + emit(state.copyWith(settings: newSettings)); + } + + void _emitUpdatedPages(RefreshPages event, _Emit emit) { + _logger.info('_emitUpdatedPages'); + + final slotCount = state.uiSlotCapacity; + if (slotCount == 0) { + _logger.info('new pages requested but there is no slot capacity'); + return; + } + + var noOrdersInAnyShard = true; + final now = DateTime.now(); + final shardValidOrders = _allOrders.where((latte) { + final submittedTime = latte.metadata.orderSubmittedTime; + if (submittedTime != null && + now.difference(submittedTime) > _abandonedOrderAgeThreshold) { + // Latte order is way too old. + return false; + } + + final completionTime = latte.metadata.completionTime; + if (completionTime != null && + now.difference(completionTime) > state.settings.maxShowAge) { + // Latte is stale. + return false; + } + + noOrdersInAnyShard = false; + + if (!_doesOrderBelongToShard( + latte, + shardNumber: state.settings.shardNumber, + shardsTotal: state.settings.shardTotal, + )) { + // Latte order doesn't belong here. + return false; + } + + return true; + }).toList()..sort(_compareOrders); + + final List ordersToShow; + if (state.settings.isTopScreen) { + // Only the first N orders are on the top screen. + ordersToShow = shardValidOrders + .take(state.settings.topOrdersCount) + .toList(); + } else { + // Only the orders N+1 and beyond are on other screens. + ordersToShow = shardValidOrders + .skip(state.settings.topOrdersCount) + .toList(); + } + + final pages = []; + for (var offset = 0; offset < ordersToShow.length; offset += slotCount) { + pages.add( + OrderPage( + List.generate( + slotCount, + (index) => ordersToShow.optGet(offset + index), + ), + ), + ); + } + + _logger.fine( + () => + 'RequestNewPages orders: ' + '${ordersToShow.map((o) => o.metadata.describe()).join(', ')}', + ); + + emit( + state.copyWith(shownPages: pages, noOrdersInAnyShard: noOrdersInAnyShard), + ); + } + + void _persistSettings(QueueSettingsState settings) { + sharedPrefsRepository + .setString( + QueueSettingsState.sharedPrefsKey, + jsonEncode(settings.toJson()), + ) + .ignore(); + } + + FutureOr _saveNewPageUpdatePeriod( + SetPageUpdatePeriod event, + _Emit emit, + ) { + _logger.info('_saveNewPageUpdatePeriod: $event'); + + final newSettings = state.settings.copyWith( + pageUpdatePeriod: event.pageUpdatePeriod, + ); + _persistSettings(newSettings); + emit(state.copyWith(settings: newSettings)); + add(const RefreshPages()); + } + + FutureOr _saveNewRecencySetting(SetRecency event, _Emit emit) { + _logger.info('_saveNewRecencySetting: $event'); + + final newSettings = state.settings.copyWith( + maxRecentAge: event.maxRecentAge, + maxShowAge: event.maxShowAge, + ); + _persistSettings(newSettings); + emit(state.copyWith(settings: newSettings)); + add(const RefreshPages()); + } + + void _saveNewShardSetting(SetShard event, _Emit emit) { + _logger.info( + '_saveNewShardSetting: ${event.shardNumber} of ${event.shardTotal}', + ); + if (event.shardTotal < 1) { + _logger.warning('Invalid $event: shard total cannot be below 1'); + return; + } + if (event.shardNumber < 1) { + _logger.warning('Invalid $event: shard number cannot be below 1'); + return; + } + if (event.shardNumber > event.shardTotal) { + _logger.warning( + 'Invalid $event: shard number cannot be above shard total', + ); + return; + } + + final newSettings = state.settings.copyWith( + shardNumber: event.shardNumber, + shardTotal: event.shardTotal, + ); + _persistSettings(newSettings); + emit(state.copyWith(settings: newSettings)); + add(const RefreshPages()); + } + + void _saveNewSlotsCount(SlotsCounted event, _Emit emit) { + _logger.info('_saveNewSlotsCount: $event'); + if (event.slotCount == state.uiSlotCapacity) return; + _logger.info('New slot count: ${event.slotCount}'); + emit(state.copyWith(uiSlotCapacity: event.slotCount)); + if (event.slotCount > 0) { + add(const RefreshPages()); + } + } + + void _saveNewTopSetting(SetTopSettings event, _Emit emit) { + _logger.info( + '_saveNewTopSetting: isTopScreen=${event.isTopScreen}, ' + 'topOrdersCount=${event.topOrdersCount}', + ); + if (event.topOrdersCount < 0) { + _logger.warning('Invalid $event: top orders count cannot be negative'); + return; + } + + final newSettings = state.settings.copyWith( + isTopScreen: event.isTopScreen, + topOrdersCount: event.topOrdersCount, + ); + _persistSettings(newSettings); + emit(state.copyWith(settings: newSettings)); + add(const RefreshPages()); + } + + Future _updateOrdersFromRepository( + OrdersUpdated event, + _Emit emit, + ) async { + _logger.fine( + '_updateOrdersFromRepository: ' + '${event.metadatas.map((m) => m.describe())}', + ); + + List lattes; + try { + lattes = await ordersRepository.toLattes(event.metadatas); + } on DataException catch (e) { + _logger.severe(e.message); + return; + } + + for (final latte in lattes) { + assert( + latte.metadata.status != .configuring, + 'Got an order that is still configuring: $latte', + ); + } + + _allOrders = _createOrdersSet( + // Note the order is important here. We want to keep the old orders around + // but updated orders have precedence. + lattes.followedBy(_allOrders), + ); + + add(const RefreshPages()); + } + + static HashSet _createOrdersSet(Iterable contents) => HashSet( + equals: (a, b) => a.metadata.id! == b.metadata.id!, + hashCode: (latte) => latte.metadata.id!.hashCode, + )..addAll(contents); + + /// ## Sharding strategy + /// + /// The queue app can be running at more than one display at a time. We need a + /// strategy to decide which order goes on which screen. Let's call this + /// "sharding". + /// + /// Constraints: + /// + /// - Sharding must work even if there's only one screen. At that point, all + /// orders must show on that screen. + /// - Sharding should work without the need for the running apps to + /// synchronize. One display does not need to know about the other. + /// - Both displays should have a comparable number of orders to display. It + /// shouldn't be usual to see many orders on one screen and no orders on the + /// others, for example. + /// - When situations arise during an event, it should be possible to adapt on + /// the spot. For example, if one display goes down, it shouldn't require + /// touching the database (change order metadata) to make the other + /// display(s) take over responsibilities. Similarly, if a display is added + /// on day 2 of event, no need to touch the database. + /// + /// Alternatives considered: + /// + /// - A continuous flow of orders from one display to the other. A single + /// list. This would require synchronization of the devices. It would be + /// common to have empty displays (except the first one). It would be gnarly + /// to figure out what happens when the alotted number of screens is not + /// enough to show all orders (how do we rotate something like this?). + /// - Have one screen for "serving now", and the second for all orders. + /// Doesn't solve for more than two displays. Doesn't scale down as easily + /// to one display. + /// - Server-driven sharding. Saves on traffic to clients but is much less + /// nimble, and requires more code. + /// + /// Solution: + /// + /// - For the sake of simplicity, let's assume there are two devices, Left + /// Display and Right Display. + /// - This means that `shardsTotal == 2`. We assume different shard numbers to + /// the displays, so Left Display can be `shardNumber == 1` and Right + /// Display can be `shardNumber == 2`. + /// - The Queue app has a "setup" overlay that lets us change the shards total + /// and the current display's shard number at runtime. + /// - Let's assume we're on Right Display, shard number is 2 (of 2). + /// - A new list of orders comes in from the Server. + /// - For each order to be displayed: + /// - We take its `id` --> a String like `cskAU2zymqpk0qOCRQb9`) + /// - We get a hash of the string --> a number like `4846121616` + /// - We modulo the hash by the number of shards --> a remainder like `0` + /// - We compare the remainder with the shard number (minus 1) --> `0 != 1` + /// - If the remainder doesn't equal the shard number, the order is not for + /// this screen + /// - The rest of the code just ignores the existence of the other orders. It + /// can decide to paginate the orders if it needs to, while the other screen + /// can decide not to paginate because it has fewer orders, and so on. + /// + /// There are some important details to consider: + /// + /// - The hashing function must have a reasonably uniform distribution. That's + /// why I switched from using `String.hashCode`, which sometimes had way too + /// many hits for one shard and way too few for the other. After switching + /// to `pkg:hashlib`, it's much better. Though it's still _possible_ to have + /// very uneven distribution between shards, it's much less likely. + /// - The model for `LatteOrder` suggests that `id` can be `null`, at which + /// point I'm not sure what to do. I assume this is pretty much guaranteed + /// to never happen (at least for orders seen by the client), + /// so we just show such order on all shards to be safe (and log a warning). + static bool _doesOrderBelongToShard( + Latte latte, { + required int shardNumber, + required int shardsTotal, + }) { + assert(shardsTotal > 0, 'Must have at least one shard'); + assert(shardNumber > 0, 'Shard number must be at least 1'); + assert( + shardNumber <= shardsTotal, + 'Shard number must never be higher than total number of shards', + ); + if (shardsTotal == 1) { + return true; + } + final id = latte.order.id; + if (id == null) { + _logger.warning('Order has null id: $latte'); + // It's probably safer to show a latte than to hide it. + return true; + } else { + // Instead of String.hashCode, which returns clustered hashes + // (meaning that one shard can easily get many more orders + // than all other shards), + // this `pkg:hashlib` function returns a more uniform distribution + // while still being very fast (it's not cryptographic). + final hash = xxh32code(id); + final modulo = hash % shardsTotal; + return modulo == (shardNumber - 1); + } + } +} + +/// Actions that can be taken on the QueueHome page. +@Freezed() +sealed class QueueHomeEvent with _$QueueHomeEvent { + /// This event is generated when the set of orders is updated in the + /// repository (typically in Firebase). + const factory QueueHomeEvent.ordersUpdated( + List metadatas, + ) = OrdersUpdated; + + /// Request for new pages, immediately. + const factory QueueHomeEvent.refreshPages() = RefreshPages; + + /// Sets up a new custom page update period. + const factory QueueHomeEvent.setPageUpdatePeriod(Duration pageUpdatePeriod) = + SetPageUpdatePeriod; + + /// Sets up new recency thresholds. + const factory QueueHomeEvent.setRecency( + Duration maxRecentAge, + Duration maxShowAge, + ) = SetRecency; + + /// Sets up new sharding settings. + const factory QueueHomeEvent.setShard(int shardNumber, int shardTotal) = + SetShard; + + /// Sets up a new target row height. + const factory QueueHomeEvent.setTargetRowHeight(double height) = + SetTargetRowHeight; + + /// Sets up top settings. + const factory QueueHomeEvent.setTopSettings({ + required bool isTopScreen, + required int topOrdersCount, + }) = SetTopSettings; + + /// Event sent by the view to inform the bloc about available space. + const factory QueueHomeEvent.slotsCounted( + int slotCount, + ) = SlotsCounted; +} + +/// {@template QueueHomeState} +/// Complete representation of the QueueHome page's state. +/// {@endtemplate +@Freezed() +sealed class QueueHomeState with _$QueueHomeState { + /// {@macro QueueHomeState} + const factory QueueHomeState({ + /// The setup of the queue. Persisted. + required QueueSettingsState settings, + + /// The number of orders that the UI can show at a time. Depends on screen + /// configuration, and informs how many orders will be in each [OrderPage]. + @Default(0) int uiSlotCapacity, + + /// The orders to show. Will be split into pages by [uiSlotCapacity]. + @Default([]) List shownPages, + + /// When this is true, we know that no orders are currently in the pipeline. + /// This is different from simply no orders being available + /// for _this shard_. The UI can show a message or a "screen saver". + @Default(false) bool noOrdersInAnyShard, + }) = _QueueHomeState; + + const QueueHomeState._(); +} diff --git a/genlatte/genlatte-ui/lib/src/screens/queue/home/queue_home_bloc.freezed.dart b/genlatte/genlatte-ui/lib/src/screens/queue/home/queue_home_bloc.freezed.dart new file mode 100644 index 0000000..1efd80b --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/screens/queue/home/queue_home_bloc.freezed.dart @@ -0,0 +1,1019 @@ +// Copyright 2014 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. + +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'queue_home_bloc.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; +/// @nodoc +mixin _$QueueHomeEvent { + + + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is QueueHomeEvent); +} + + +@override +int get hashCode => runtimeType.hashCode; + +@override +String toString() { + return 'QueueHomeEvent()'; +} + + +} + +/// @nodoc +class $QueueHomeEventCopyWith<$Res> { +$QueueHomeEventCopyWith(QueueHomeEvent _, $Res Function(QueueHomeEvent) __); +} + + +/// Adds pattern-matching-related methods to [QueueHomeEvent]. +extension QueueHomeEventPatterns on QueueHomeEvent { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap({TResult Function( OrdersUpdated value)? ordersUpdated,TResult Function( RefreshPages value)? refreshPages,TResult Function( SetPageUpdatePeriod value)? setPageUpdatePeriod,TResult Function( SetRecency value)? setRecency,TResult Function( SetShard value)? setShard,TResult Function( SetTargetRowHeight value)? setTargetRowHeight,TResult Function( SetTopSettings value)? setTopSettings,TResult Function( SlotsCounted value)? slotsCounted,required TResult orElse(),}){ +final _that = this; +switch (_that) { +case OrdersUpdated() when ordersUpdated != null: +return ordersUpdated(_that);case RefreshPages() when refreshPages != null: +return refreshPages(_that);case SetPageUpdatePeriod() when setPageUpdatePeriod != null: +return setPageUpdatePeriod(_that);case SetRecency() when setRecency != null: +return setRecency(_that);case SetShard() when setShard != null: +return setShard(_that);case SetTargetRowHeight() when setTargetRowHeight != null: +return setTargetRowHeight(_that);case SetTopSettings() when setTopSettings != null: +return setTopSettings(_that);case SlotsCounted() when slotsCounted != null: +return slotsCounted(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map({required TResult Function( OrdersUpdated value) ordersUpdated,required TResult Function( RefreshPages value) refreshPages,required TResult Function( SetPageUpdatePeriod value) setPageUpdatePeriod,required TResult Function( SetRecency value) setRecency,required TResult Function( SetShard value) setShard,required TResult Function( SetTargetRowHeight value) setTargetRowHeight,required TResult Function( SetTopSettings value) setTopSettings,required TResult Function( SlotsCounted value) slotsCounted,}){ +final _that = this; +switch (_that) { +case OrdersUpdated(): +return ordersUpdated(_that);case RefreshPages(): +return refreshPages(_that);case SetPageUpdatePeriod(): +return setPageUpdatePeriod(_that);case SetRecency(): +return setRecency(_that);case SetShard(): +return setShard(_that);case SetTargetRowHeight(): +return setTargetRowHeight(_that);case SetTopSettings(): +return setTopSettings(_that);case SlotsCounted(): +return slotsCounted(_that);} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull({TResult? Function( OrdersUpdated value)? ordersUpdated,TResult? Function( RefreshPages value)? refreshPages,TResult? Function( SetPageUpdatePeriod value)? setPageUpdatePeriod,TResult? Function( SetRecency value)? setRecency,TResult? Function( SetShard value)? setShard,TResult? Function( SetTargetRowHeight value)? setTargetRowHeight,TResult? Function( SetTopSettings value)? setTopSettings,TResult? Function( SlotsCounted value)? slotsCounted,}){ +final _that = this; +switch (_that) { +case OrdersUpdated() when ordersUpdated != null: +return ordersUpdated(_that);case RefreshPages() when refreshPages != null: +return refreshPages(_that);case SetPageUpdatePeriod() when setPageUpdatePeriod != null: +return setPageUpdatePeriod(_that);case SetRecency() when setRecency != null: +return setRecency(_that);case SetShard() when setShard != null: +return setShard(_that);case SetTargetRowHeight() when setTargetRowHeight != null: +return setTargetRowHeight(_that);case SetTopSettings() when setTopSettings != null: +return setTopSettings(_that);case SlotsCounted() when slotsCounted != null: +return slotsCounted(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen({TResult Function( List metadatas)? ordersUpdated,TResult Function()? refreshPages,TResult Function( Duration pageUpdatePeriod)? setPageUpdatePeriod,TResult Function( Duration maxRecentAge, Duration maxShowAge)? setRecency,TResult Function( int shardNumber, int shardTotal)? setShard,TResult Function( double height)? setTargetRowHeight,TResult Function( bool isTopScreen, int topOrdersCount)? setTopSettings,TResult Function( int slotCount)? slotsCounted,required TResult orElse(),}) {final _that = this; +switch (_that) { +case OrdersUpdated() when ordersUpdated != null: +return ordersUpdated(_that.metadatas);case RefreshPages() when refreshPages != null: +return refreshPages();case SetPageUpdatePeriod() when setPageUpdatePeriod != null: +return setPageUpdatePeriod(_that.pageUpdatePeriod);case SetRecency() when setRecency != null: +return setRecency(_that.maxRecentAge,_that.maxShowAge);case SetShard() when setShard != null: +return setShard(_that.shardNumber,_that.shardTotal);case SetTargetRowHeight() when setTargetRowHeight != null: +return setTargetRowHeight(_that.height);case SetTopSettings() when setTopSettings != null: +return setTopSettings(_that.isTopScreen,_that.topOrdersCount);case SlotsCounted() when slotsCounted != null: +return slotsCounted(_that.slotCount);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when({required TResult Function( List metadatas) ordersUpdated,required TResult Function() refreshPages,required TResult Function( Duration pageUpdatePeriod) setPageUpdatePeriod,required TResult Function( Duration maxRecentAge, Duration maxShowAge) setRecency,required TResult Function( int shardNumber, int shardTotal) setShard,required TResult Function( double height) setTargetRowHeight,required TResult Function( bool isTopScreen, int topOrdersCount) setTopSettings,required TResult Function( int slotCount) slotsCounted,}) {final _that = this; +switch (_that) { +case OrdersUpdated(): +return ordersUpdated(_that.metadatas);case RefreshPages(): +return refreshPages();case SetPageUpdatePeriod(): +return setPageUpdatePeriod(_that.pageUpdatePeriod);case SetRecency(): +return setRecency(_that.maxRecentAge,_that.maxShowAge);case SetShard(): +return setShard(_that.shardNumber,_that.shardTotal);case SetTargetRowHeight(): +return setTargetRowHeight(_that.height);case SetTopSettings(): +return setTopSettings(_that.isTopScreen,_that.topOrdersCount);case SlotsCounted(): +return slotsCounted(_that.slotCount);} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull({TResult? Function( List metadatas)? ordersUpdated,TResult? Function()? refreshPages,TResult? Function( Duration pageUpdatePeriod)? setPageUpdatePeriod,TResult? Function( Duration maxRecentAge, Duration maxShowAge)? setRecency,TResult? Function( int shardNumber, int shardTotal)? setShard,TResult? Function( double height)? setTargetRowHeight,TResult? Function( bool isTopScreen, int topOrdersCount)? setTopSettings,TResult? Function( int slotCount)? slotsCounted,}) {final _that = this; +switch (_that) { +case OrdersUpdated() when ordersUpdated != null: +return ordersUpdated(_that.metadatas);case RefreshPages() when refreshPages != null: +return refreshPages();case SetPageUpdatePeriod() when setPageUpdatePeriod != null: +return setPageUpdatePeriod(_that.pageUpdatePeriod);case SetRecency() when setRecency != null: +return setRecency(_that.maxRecentAge,_that.maxShowAge);case SetShard() when setShard != null: +return setShard(_that.shardNumber,_that.shardTotal);case SetTargetRowHeight() when setTargetRowHeight != null: +return setTargetRowHeight(_that.height);case SetTopSettings() when setTopSettings != null: +return setTopSettings(_that.isTopScreen,_that.topOrdersCount);case SlotsCounted() when slotsCounted != null: +return slotsCounted(_that.slotCount);case _: + return null; + +} +} + +} + +/// @nodoc + + +class OrdersUpdated implements QueueHomeEvent { + const OrdersUpdated(final List metadatas): _metadatas = metadatas; + + + final List _metadatas; + List get metadatas { + if (_metadatas is EqualUnmodifiableListView) return _metadatas; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_metadatas); +} + + +/// Create a copy of QueueHomeEvent +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$OrdersUpdatedCopyWith get copyWith => _$OrdersUpdatedCopyWithImpl(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is OrdersUpdated&&const DeepCollectionEquality().equals(other._metadatas, _metadatas)); +} + + +@override +int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(_metadatas)); + +@override +String toString() { + return 'QueueHomeEvent.ordersUpdated(metadatas: $metadatas)'; +} + + +} + +/// @nodoc +abstract mixin class $OrdersUpdatedCopyWith<$Res> implements $QueueHomeEventCopyWith<$Res> { + factory $OrdersUpdatedCopyWith(OrdersUpdated value, $Res Function(OrdersUpdated) _then) = _$OrdersUpdatedCopyWithImpl; +@useResult +$Res call({ + List metadatas +}); + + + + +} +/// @nodoc +class _$OrdersUpdatedCopyWithImpl<$Res> + implements $OrdersUpdatedCopyWith<$Res> { + _$OrdersUpdatedCopyWithImpl(this._self, this._then); + + final OrdersUpdated _self; + final $Res Function(OrdersUpdated) _then; + +/// Create a copy of QueueHomeEvent +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') $Res call({Object? metadatas = null,}) { + return _then(OrdersUpdated( +null == metadatas ? _self._metadatas : metadatas // ignore: cast_nullable_to_non_nullable +as List, + )); +} + + +} + +/// @nodoc + + +class RefreshPages implements QueueHomeEvent { + const RefreshPages(); + + + + + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is RefreshPages); +} + + +@override +int get hashCode => runtimeType.hashCode; + +@override +String toString() { + return 'QueueHomeEvent.refreshPages()'; +} + + +} + + + + +/// @nodoc + + +class SetPageUpdatePeriod implements QueueHomeEvent { + const SetPageUpdatePeriod(this.pageUpdatePeriod); + + + final Duration pageUpdatePeriod; + +/// Create a copy of QueueHomeEvent +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$SetPageUpdatePeriodCopyWith get copyWith => _$SetPageUpdatePeriodCopyWithImpl(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is SetPageUpdatePeriod&&(identical(other.pageUpdatePeriod, pageUpdatePeriod) || other.pageUpdatePeriod == pageUpdatePeriod)); +} + + +@override +int get hashCode => Object.hash(runtimeType,pageUpdatePeriod); + +@override +String toString() { + return 'QueueHomeEvent.setPageUpdatePeriod(pageUpdatePeriod: $pageUpdatePeriod)'; +} + + +} + +/// @nodoc +abstract mixin class $SetPageUpdatePeriodCopyWith<$Res> implements $QueueHomeEventCopyWith<$Res> { + factory $SetPageUpdatePeriodCopyWith(SetPageUpdatePeriod value, $Res Function(SetPageUpdatePeriod) _then) = _$SetPageUpdatePeriodCopyWithImpl; +@useResult +$Res call({ + Duration pageUpdatePeriod +}); + + + + +} +/// @nodoc +class _$SetPageUpdatePeriodCopyWithImpl<$Res> + implements $SetPageUpdatePeriodCopyWith<$Res> { + _$SetPageUpdatePeriodCopyWithImpl(this._self, this._then); + + final SetPageUpdatePeriod _self; + final $Res Function(SetPageUpdatePeriod) _then; + +/// Create a copy of QueueHomeEvent +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') $Res call({Object? pageUpdatePeriod = null,}) { + return _then(SetPageUpdatePeriod( +null == pageUpdatePeriod ? _self.pageUpdatePeriod : pageUpdatePeriod // ignore: cast_nullable_to_non_nullable +as Duration, + )); +} + + +} + +/// @nodoc + + +class SetRecency implements QueueHomeEvent { + const SetRecency(this.maxRecentAge, this.maxShowAge); + + + final Duration maxRecentAge; + final Duration maxShowAge; + +/// Create a copy of QueueHomeEvent +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$SetRecencyCopyWith get copyWith => _$SetRecencyCopyWithImpl(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is SetRecency&&(identical(other.maxRecentAge, maxRecentAge) || other.maxRecentAge == maxRecentAge)&&(identical(other.maxShowAge, maxShowAge) || other.maxShowAge == maxShowAge)); +} + + +@override +int get hashCode => Object.hash(runtimeType,maxRecentAge,maxShowAge); + +@override +String toString() { + return 'QueueHomeEvent.setRecency(maxRecentAge: $maxRecentAge, maxShowAge: $maxShowAge)'; +} + + +} + +/// @nodoc +abstract mixin class $SetRecencyCopyWith<$Res> implements $QueueHomeEventCopyWith<$Res> { + factory $SetRecencyCopyWith(SetRecency value, $Res Function(SetRecency) _then) = _$SetRecencyCopyWithImpl; +@useResult +$Res call({ + Duration maxRecentAge, Duration maxShowAge +}); + + + + +} +/// @nodoc +class _$SetRecencyCopyWithImpl<$Res> + implements $SetRecencyCopyWith<$Res> { + _$SetRecencyCopyWithImpl(this._self, this._then); + + final SetRecency _self; + final $Res Function(SetRecency) _then; + +/// Create a copy of QueueHomeEvent +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') $Res call({Object? maxRecentAge = null,Object? maxShowAge = null,}) { + return _then(SetRecency( +null == maxRecentAge ? _self.maxRecentAge : maxRecentAge // ignore: cast_nullable_to_non_nullable +as Duration,null == maxShowAge ? _self.maxShowAge : maxShowAge // ignore: cast_nullable_to_non_nullable +as Duration, + )); +} + + +} + +/// @nodoc + + +class SetShard implements QueueHomeEvent { + const SetShard(this.shardNumber, this.shardTotal); + + + final int shardNumber; + final int shardTotal; + +/// Create a copy of QueueHomeEvent +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$SetShardCopyWith get copyWith => _$SetShardCopyWithImpl(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is SetShard&&(identical(other.shardNumber, shardNumber) || other.shardNumber == shardNumber)&&(identical(other.shardTotal, shardTotal) || other.shardTotal == shardTotal)); +} + + +@override +int get hashCode => Object.hash(runtimeType,shardNumber,shardTotal); + +@override +String toString() { + return 'QueueHomeEvent.setShard(shardNumber: $shardNumber, shardTotal: $shardTotal)'; +} + + +} + +/// @nodoc +abstract mixin class $SetShardCopyWith<$Res> implements $QueueHomeEventCopyWith<$Res> { + factory $SetShardCopyWith(SetShard value, $Res Function(SetShard) _then) = _$SetShardCopyWithImpl; +@useResult +$Res call({ + int shardNumber, int shardTotal +}); + + + + +} +/// @nodoc +class _$SetShardCopyWithImpl<$Res> + implements $SetShardCopyWith<$Res> { + _$SetShardCopyWithImpl(this._self, this._then); + + final SetShard _self; + final $Res Function(SetShard) _then; + +/// Create a copy of QueueHomeEvent +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') $Res call({Object? shardNumber = null,Object? shardTotal = null,}) { + return _then(SetShard( +null == shardNumber ? _self.shardNumber : shardNumber // ignore: cast_nullable_to_non_nullable +as int,null == shardTotal ? _self.shardTotal : shardTotal // ignore: cast_nullable_to_non_nullable +as int, + )); +} + + +} + +/// @nodoc + + +class SetTargetRowHeight implements QueueHomeEvent { + const SetTargetRowHeight(this.height); + + + final double height; + +/// Create a copy of QueueHomeEvent +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$SetTargetRowHeightCopyWith get copyWith => _$SetTargetRowHeightCopyWithImpl(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is SetTargetRowHeight&&(identical(other.height, height) || other.height == height)); +} + + +@override +int get hashCode => Object.hash(runtimeType,height); + +@override +String toString() { + return 'QueueHomeEvent.setTargetRowHeight(height: $height)'; +} + + +} + +/// @nodoc +abstract mixin class $SetTargetRowHeightCopyWith<$Res> implements $QueueHomeEventCopyWith<$Res> { + factory $SetTargetRowHeightCopyWith(SetTargetRowHeight value, $Res Function(SetTargetRowHeight) _then) = _$SetTargetRowHeightCopyWithImpl; +@useResult +$Res call({ + double height +}); + + + + +} +/// @nodoc +class _$SetTargetRowHeightCopyWithImpl<$Res> + implements $SetTargetRowHeightCopyWith<$Res> { + _$SetTargetRowHeightCopyWithImpl(this._self, this._then); + + final SetTargetRowHeight _self; + final $Res Function(SetTargetRowHeight) _then; + +/// Create a copy of QueueHomeEvent +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') $Res call({Object? height = null,}) { + return _then(SetTargetRowHeight( +null == height ? _self.height : height // ignore: cast_nullable_to_non_nullable +as double, + )); +} + + +} + +/// @nodoc + + +class SetTopSettings implements QueueHomeEvent { + const SetTopSettings({required this.isTopScreen, required this.topOrdersCount}); + + + final bool isTopScreen; + final int topOrdersCount; + +/// Create a copy of QueueHomeEvent +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$SetTopSettingsCopyWith get copyWith => _$SetTopSettingsCopyWithImpl(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is SetTopSettings&&(identical(other.isTopScreen, isTopScreen) || other.isTopScreen == isTopScreen)&&(identical(other.topOrdersCount, topOrdersCount) || other.topOrdersCount == topOrdersCount)); +} + + +@override +int get hashCode => Object.hash(runtimeType,isTopScreen,topOrdersCount); + +@override +String toString() { + return 'QueueHomeEvent.setTopSettings(isTopScreen: $isTopScreen, topOrdersCount: $topOrdersCount)'; +} + + +} + +/// @nodoc +abstract mixin class $SetTopSettingsCopyWith<$Res> implements $QueueHomeEventCopyWith<$Res> { + factory $SetTopSettingsCopyWith(SetTopSettings value, $Res Function(SetTopSettings) _then) = _$SetTopSettingsCopyWithImpl; +@useResult +$Res call({ + bool isTopScreen, int topOrdersCount +}); + + + + +} +/// @nodoc +class _$SetTopSettingsCopyWithImpl<$Res> + implements $SetTopSettingsCopyWith<$Res> { + _$SetTopSettingsCopyWithImpl(this._self, this._then); + + final SetTopSettings _self; + final $Res Function(SetTopSettings) _then; + +/// Create a copy of QueueHomeEvent +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') $Res call({Object? isTopScreen = null,Object? topOrdersCount = null,}) { + return _then(SetTopSettings( +isTopScreen: null == isTopScreen ? _self.isTopScreen : isTopScreen // ignore: cast_nullable_to_non_nullable +as bool,topOrdersCount: null == topOrdersCount ? _self.topOrdersCount : topOrdersCount // ignore: cast_nullable_to_non_nullable +as int, + )); +} + + +} + +/// @nodoc + + +class SlotsCounted implements QueueHomeEvent { + const SlotsCounted(this.slotCount); + + + final int slotCount; + +/// Create a copy of QueueHomeEvent +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$SlotsCountedCopyWith get copyWith => _$SlotsCountedCopyWithImpl(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is SlotsCounted&&(identical(other.slotCount, slotCount) || other.slotCount == slotCount)); +} + + +@override +int get hashCode => Object.hash(runtimeType,slotCount); + +@override +String toString() { + return 'QueueHomeEvent.slotsCounted(slotCount: $slotCount)'; +} + + +} + +/// @nodoc +abstract mixin class $SlotsCountedCopyWith<$Res> implements $QueueHomeEventCopyWith<$Res> { + factory $SlotsCountedCopyWith(SlotsCounted value, $Res Function(SlotsCounted) _then) = _$SlotsCountedCopyWithImpl; +@useResult +$Res call({ + int slotCount +}); + + + + +} +/// @nodoc +class _$SlotsCountedCopyWithImpl<$Res> + implements $SlotsCountedCopyWith<$Res> { + _$SlotsCountedCopyWithImpl(this._self, this._then); + + final SlotsCounted _self; + final $Res Function(SlotsCounted) _then; + +/// Create a copy of QueueHomeEvent +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') $Res call({Object? slotCount = null,}) { + return _then(SlotsCounted( +null == slotCount ? _self.slotCount : slotCount // ignore: cast_nullable_to_non_nullable +as int, + )); +} + + +} + +/// @nodoc +mixin _$QueueHomeState { + +/// The setup of the queue. Persisted. + QueueSettingsState get settings;/// The number of orders that the UI can show at a time. Depends on screen +/// configuration, and informs how many orders will be in each [OrderPage]. + int get uiSlotCapacity;/// The orders to show. Will be split into pages by [uiSlotCapacity]. + List get shownPages;/// When this is true, we know that no orders are currently in the pipeline. +/// This is different from simply no orders being available +/// for _this shard_. The UI can show a message or a "screen saver". + bool get noOrdersInAnyShard; +/// Create a copy of QueueHomeState +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$QueueHomeStateCopyWith get copyWith => _$QueueHomeStateCopyWithImpl(this as QueueHomeState, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is QueueHomeState&&(identical(other.settings, settings) || other.settings == settings)&&(identical(other.uiSlotCapacity, uiSlotCapacity) || other.uiSlotCapacity == uiSlotCapacity)&&const DeepCollectionEquality().equals(other.shownPages, shownPages)&&(identical(other.noOrdersInAnyShard, noOrdersInAnyShard) || other.noOrdersInAnyShard == noOrdersInAnyShard)); +} + + +@override +int get hashCode => Object.hash(runtimeType,settings,uiSlotCapacity,const DeepCollectionEquality().hash(shownPages),noOrdersInAnyShard); + +@override +String toString() { + return 'QueueHomeState(settings: $settings, uiSlotCapacity: $uiSlotCapacity, shownPages: $shownPages, noOrdersInAnyShard: $noOrdersInAnyShard)'; +} + + +} + +/// @nodoc +abstract mixin class $QueueHomeStateCopyWith<$Res> { + factory $QueueHomeStateCopyWith(QueueHomeState value, $Res Function(QueueHomeState) _then) = _$QueueHomeStateCopyWithImpl; +@useResult +$Res call({ + QueueSettingsState settings, int uiSlotCapacity, List shownPages, bool noOrdersInAnyShard +}); + + +$QueueSettingsStateCopyWith<$Res> get settings; + +} +/// @nodoc +class _$QueueHomeStateCopyWithImpl<$Res> + implements $QueueHomeStateCopyWith<$Res> { + _$QueueHomeStateCopyWithImpl(this._self, this._then); + + final QueueHomeState _self; + final $Res Function(QueueHomeState) _then; + +/// Create a copy of QueueHomeState +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? settings = null,Object? uiSlotCapacity = null,Object? shownPages = null,Object? noOrdersInAnyShard = null,}) { + return _then(_self.copyWith( +settings: null == settings ? _self.settings : settings // ignore: cast_nullable_to_non_nullable +as QueueSettingsState,uiSlotCapacity: null == uiSlotCapacity ? _self.uiSlotCapacity : uiSlotCapacity // ignore: cast_nullable_to_non_nullable +as int,shownPages: null == shownPages ? _self.shownPages : shownPages // ignore: cast_nullable_to_non_nullable +as List,noOrdersInAnyShard: null == noOrdersInAnyShard ? _self.noOrdersInAnyShard : noOrdersInAnyShard // ignore: cast_nullable_to_non_nullable +as bool, + )); +} +/// Create a copy of QueueHomeState +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$QueueSettingsStateCopyWith<$Res> get settings { + + return $QueueSettingsStateCopyWith<$Res>(_self.settings, (value) { + return _then(_self.copyWith(settings: value)); + }); +} +} + + +/// Adds pattern-matching-related methods to [QueueHomeState]. +extension QueueHomeStatePatterns on QueueHomeState { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _QueueHomeState value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _QueueHomeState() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _QueueHomeState value) $default,){ +final _that = this; +switch (_that) { +case _QueueHomeState(): +return $default(_that);} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _QueueHomeState value)? $default,){ +final _that = this; +switch (_that) { +case _QueueHomeState() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( QueueSettingsState settings, int uiSlotCapacity, List shownPages, bool noOrdersInAnyShard)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _QueueHomeState() when $default != null: +return $default(_that.settings,_that.uiSlotCapacity,_that.shownPages,_that.noOrdersInAnyShard);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( QueueSettingsState settings, int uiSlotCapacity, List shownPages, bool noOrdersInAnyShard) $default,) {final _that = this; +switch (_that) { +case _QueueHomeState(): +return $default(_that.settings,_that.uiSlotCapacity,_that.shownPages,_that.noOrdersInAnyShard);} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( QueueSettingsState settings, int uiSlotCapacity, List shownPages, bool noOrdersInAnyShard)? $default,) {final _that = this; +switch (_that) { +case _QueueHomeState() when $default != null: +return $default(_that.settings,_that.uiSlotCapacity,_that.shownPages,_that.noOrdersInAnyShard);case _: + return null; + +} +} + +} + +/// @nodoc + + +class _QueueHomeState extends QueueHomeState { + const _QueueHomeState({required this.settings, this.uiSlotCapacity = 0, final List shownPages = const [], this.noOrdersInAnyShard = false}): _shownPages = shownPages,super._(); + + +/// The setup of the queue. Persisted. +@override final QueueSettingsState settings; +/// The number of orders that the UI can show at a time. Depends on screen +/// configuration, and informs how many orders will be in each [OrderPage]. +@override@JsonKey() final int uiSlotCapacity; +/// The orders to show. Will be split into pages by [uiSlotCapacity]. + final List _shownPages; +/// The orders to show. Will be split into pages by [uiSlotCapacity]. +@override@JsonKey() List get shownPages { + if (_shownPages is EqualUnmodifiableListView) return _shownPages; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_shownPages); +} + +/// When this is true, we know that no orders are currently in the pipeline. +/// This is different from simply no orders being available +/// for _this shard_. The UI can show a message or a "screen saver". +@override@JsonKey() final bool noOrdersInAnyShard; + +/// Create a copy of QueueHomeState +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$QueueHomeStateCopyWith<_QueueHomeState> get copyWith => __$QueueHomeStateCopyWithImpl<_QueueHomeState>(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _QueueHomeState&&(identical(other.settings, settings) || other.settings == settings)&&(identical(other.uiSlotCapacity, uiSlotCapacity) || other.uiSlotCapacity == uiSlotCapacity)&&const DeepCollectionEquality().equals(other._shownPages, _shownPages)&&(identical(other.noOrdersInAnyShard, noOrdersInAnyShard) || other.noOrdersInAnyShard == noOrdersInAnyShard)); +} + + +@override +int get hashCode => Object.hash(runtimeType,settings,uiSlotCapacity,const DeepCollectionEquality().hash(_shownPages),noOrdersInAnyShard); + +@override +String toString() { + return 'QueueHomeState(settings: $settings, uiSlotCapacity: $uiSlotCapacity, shownPages: $shownPages, noOrdersInAnyShard: $noOrdersInAnyShard)'; +} + + +} + +/// @nodoc +abstract mixin class _$QueueHomeStateCopyWith<$Res> implements $QueueHomeStateCopyWith<$Res> { + factory _$QueueHomeStateCopyWith(_QueueHomeState value, $Res Function(_QueueHomeState) _then) = __$QueueHomeStateCopyWithImpl; +@override @useResult +$Res call({ + QueueSettingsState settings, int uiSlotCapacity, List shownPages, bool noOrdersInAnyShard +}); + + +@override $QueueSettingsStateCopyWith<$Res> get settings; + +} +/// @nodoc +class __$QueueHomeStateCopyWithImpl<$Res> + implements _$QueueHomeStateCopyWith<$Res> { + __$QueueHomeStateCopyWithImpl(this._self, this._then); + + final _QueueHomeState _self; + final $Res Function(_QueueHomeState) _then; + +/// Create a copy of QueueHomeState +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? settings = null,Object? uiSlotCapacity = null,Object? shownPages = null,Object? noOrdersInAnyShard = null,}) { + return _then(_QueueHomeState( +settings: null == settings ? _self.settings : settings // ignore: cast_nullable_to_non_nullable +as QueueSettingsState,uiSlotCapacity: null == uiSlotCapacity ? _self.uiSlotCapacity : uiSlotCapacity // ignore: cast_nullable_to_non_nullable +as int,shownPages: null == shownPages ? _self._shownPages : shownPages // ignore: cast_nullable_to_non_nullable +as List,noOrdersInAnyShard: null == noOrdersInAnyShard ? _self.noOrdersInAnyShard : noOrdersInAnyShard // ignore: cast_nullable_to_non_nullable +as bool, + )); +} + +/// Create a copy of QueueHomeState +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$QueueSettingsStateCopyWith<$Res> get settings { + + return $QueueSettingsStateCopyWith<$Res>(_self.settings, (value) { + return _then(_self.copyWith(settings: value)); + }); +} +} + +// dart format on diff --git a/genlatte/genlatte-ui/lib/src/screens/queue/home/queue_home_view.dart b/genlatte/genlatte-ui/lib/src/screens/queue/home/queue_home_view.dart new file mode 100644 index 0000000..cd10c56 --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/screens/queue/home/queue_home_view.dart @@ -0,0 +1,156 @@ +// Copyright 2014 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:data_layer/data_layer.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:genlatte/src/data/data.dart'; +import 'package:genlatte/src/data/shared_preferences_repository.dart'; +import 'package:genlatte/src/screens/app/app.dart'; +import 'package:genlatte/src/screens/queue/home/fake_repositories.dart'; +import 'package:genlatte/src/screens/queue/home/queue_home.dart'; +import 'package:genlatte/src/screens/queue/widgets/debug_overlay.dart'; +import 'package:genlatte/src/screens/queue/widgets/order_list.dart'; +import 'package:genlatte_data/models.dart'; +import 'package:get_it/get_it.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +/// {@template QueueHomeScreen} +/// Initial QueueHome screen. +/// {@endtemplate} +class QueueHomeScreen extends StatefulWidget { + /// {@macro QueueHomeScreen} + const QueueHomeScreen({super.key}); + + @override + State createState() => _QueueHomeScreenState(); +} + +class _QueueHomeScreenState extends State { + late QueueHomeBloc bloc; + + FakeLatteOrdersRepository? _fakeOrdersRepository; + + FakeLatteOrdersMetadataRepository? _fakeMetadataRepository; + + bool _showDebugOverlay = false; + + bool _hoverDebugOverlay = false; + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: AppColors.white, + child: Stack( + children: [ + BlocBuilder( + bloc: bloc, + buildWhen: (previous, current) => + previous.shownPages != current.shownPages || + previous.settings != current.settings, + builder: (context, state) => OrderList( + pages: state.shownPages, + onSlotsCounted: (slots) => bloc.add(SlotsCounted(slots)), + maxRecentAge: state.settings.maxRecentAge, + targetRowHeight: state.settings.targetRowHeight, + noOrdersInAnyShard: state.noOrdersInAnyShard, + pageUpdatePeriod: state.settings.pageUpdatePeriod, + ), + ), + Positioned( + width: 100, + height: 100, + top: 10, + right: 10, + child: StatefulBuilder( + builder: (context, innerSetState) { + return MouseRegion( + cursor: SystemMouseCursors.click, + onEnter: (_) => + innerSetState(() => _hoverDebugOverlay = true), + onExit: (_) => + innerSetState(() => _hoverDebugOverlay = false), + child: ColoredBox( + color: _hoverDebugOverlay + ? AppColors.chevronYellow.withValues(alpha: 0.75) + : AppColors.transparent, + child: GestureDetector( + key: const Key('hidden_overlay_show_toggle'), + onTap: () => setState( + () => _showDebugOverlay = !_showDebugOverlay, + ), + + behavior: .opaque, + ), + ), + ); + }, + ), + ), + if (_showDebugOverlay) + Positioned( + top: 120, + bottom: 20, + right: 20, + child: Align( + alignment: .bottomEnd, + child: Opacity( + opacity: 0.9, + child: SingleChildScrollView( + child: OrderBoardDebugOverlay( + bloc: bloc, + ordersRepository: _fakeOrdersRepository, + metadataRepository: _fakeMetadataRepository, + ), + ), + ), + ), + ), + ], + ), + ); + } + + @override + void didUpdateWidget(covariant QueueHomeScreen oldWidget) { + super.didUpdateWidget(oldWidget); + + final previousBloc = bloc; + bloc = _setupBloc(); + previousBloc.close().ignore(); + } + + @override + Future dispose() async { + unawaited(bloc.close()); + super.dispose(); + } + + @override + void initState() { + super.initState(); + bloc = _setupBloc(); + } + + QueueHomeBloc _setupBloc() { + final LatteOrdersRepository ordersRepo; + final Repository metadataRepo; + // if (widget.useFakes) { + // ordersRepo = _fakeOrdersRepository = FakeLatteOrdersRepository(); + // metadataRepo = _fakeMetadataRepository = + // FakeLatteOrdersMetadataRepository(); + // } else { + ordersRepo = GetIt.I(); + metadataRepo = GetIt.I>(); + // } + final sharedPrefsRepo = GetIt.I(); + + return QueueHomeBloc( + ordersRepository: ordersRepo, + metadataRepository: metadataRepo, + sharedPrefsRepository: sharedPrefsRepo, + ); + } +} diff --git a/genlatte/genlatte-ui/lib/src/screens/queue/home/queue_settings.dart b/genlatte/genlatte-ui/lib/src/screens/queue/home/queue_settings.dart new file mode 100644 index 0000000..e13e750 --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/screens/queue/home/queue_settings.dart @@ -0,0 +1,74 @@ +// Copyright 2014 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:freezed_annotation/freezed_annotation.dart'; +import 'package:genlatte/src/data/shared_preferences_repository.dart'; +import 'package:logging/logging.dart'; + +part 'queue_settings.freezed.dart'; +part 'queue_settings.g.dart'; + +@Freezed() +sealed class QueueSettingsState with _$QueueSettingsState { + /// {@macro QueueSettings} + const factory QueueSettingsState({ + /// If set to `true`, this screen will only show the first page and will + /// never paginate. + /// + /// If set to `false`, this screen works normally except it ignores + /// the first [topOrdersCount] of orders. + @Default(false) bool isTopScreen, + + /// Asks the screen to ignore the first N orders (because, presumably, + /// they are shown on the top screen (see [isTopScreen]). + @Default(0) int topOrdersCount, + + /// The shard number of this device. + @Default(1) int shardNumber, + + /// The total number of shards in play. + @Default(1) int shardTotal, + + /// The maximum age of a completed order, after which the order will not + /// be shown anymore. + @Default(Duration(minutes: 15)) Duration maxShowAge, + + /// The duration for which an order is considered "recent". The UI may + /// choose to highlight such orders. + @Default(Duration(minutes: 5)) Duration maxRecentAge, + + /// The duration between updates of the UI. + @Default(Duration(seconds: 5)) Duration pageUpdatePeriod, + + /// A scaling factor for order rows. Can be customized in order to achieve + /// more or less dense displays (for example, when the display is farther + /// away from the customers than expected). + @Default(100) double targetRowHeight, + }) = _QueueSettingsState; + + factory QueueSettingsState.fromJson(Map json) => + _$QueueSettingsStateFromJson(json); + + const QueueSettingsState._(); + + static final Logger _logger = Logger('$QueueSettingsState'); + + static const String sharedPrefsKey = 'queue_settings'; + + static QueueSettingsState load(SharedPreferencesRepository repo) { + final jsonString = repo.getString(sharedPrefsKey); + if (jsonString != null) { + try { + return QueueSettingsState.fromJson( + jsonDecode(jsonString) as Map, + ); + } catch (e, s) { + _logger.warning('Failed to load settings', e, s); + } + } + return const QueueSettingsState(); + } +} diff --git a/genlatte/genlatte-ui/lib/src/screens/queue/home/queue_settings.freezed.dart b/genlatte/genlatte-ui/lib/src/screens/queue/home/queue_settings.freezed.dart new file mode 100644 index 0000000..cd1d01d --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/screens/queue/home/queue_settings.freezed.dart @@ -0,0 +1,330 @@ +// Copyright 2014 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. + +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'queue_settings.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; + +/// @nodoc +mixin _$QueueSettingsState { + +/// If set to `true`, this screen will only show the first page and will +/// never paginate. +/// +/// If set to `false`, this screen works normally except it ignores +/// the first [topOrdersCount] of orders. + bool get isTopScreen;/// Asks the screen to ignore the first N orders (because, presumably, +/// they are shown on the top screen (see [isTopScreen]). + int get topOrdersCount;/// The shard number of this device. + int get shardNumber;/// The total number of shards in play. + int get shardTotal;/// The maximum age of a completed order, after which the order will not +/// be shown anymore. + Duration get maxShowAge;/// The duration for which an order is considered "recent". The UI may +/// choose to highlight such orders. + Duration get maxRecentAge;/// The duration between updates of the UI. + Duration get pageUpdatePeriod;/// A scaling factor for order rows. Can be customized in order to achieve +/// more or less dense displays (for example, when the display is farther +/// away from the customers than expected). + double get targetRowHeight; +/// Create a copy of QueueSettingsState +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$QueueSettingsStateCopyWith get copyWith => _$QueueSettingsStateCopyWithImpl(this as QueueSettingsState, _$identity); + + /// Serializes this QueueSettingsState to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is QueueSettingsState&&(identical(other.isTopScreen, isTopScreen) || other.isTopScreen == isTopScreen)&&(identical(other.topOrdersCount, topOrdersCount) || other.topOrdersCount == topOrdersCount)&&(identical(other.shardNumber, shardNumber) || other.shardNumber == shardNumber)&&(identical(other.shardTotal, shardTotal) || other.shardTotal == shardTotal)&&(identical(other.maxShowAge, maxShowAge) || other.maxShowAge == maxShowAge)&&(identical(other.maxRecentAge, maxRecentAge) || other.maxRecentAge == maxRecentAge)&&(identical(other.pageUpdatePeriod, pageUpdatePeriod) || other.pageUpdatePeriod == pageUpdatePeriod)&&(identical(other.targetRowHeight, targetRowHeight) || other.targetRowHeight == targetRowHeight)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,isTopScreen,topOrdersCount,shardNumber,shardTotal,maxShowAge,maxRecentAge,pageUpdatePeriod,targetRowHeight); + +@override +String toString() { + return 'QueueSettingsState(isTopScreen: $isTopScreen, topOrdersCount: $topOrdersCount, shardNumber: $shardNumber, shardTotal: $shardTotal, maxShowAge: $maxShowAge, maxRecentAge: $maxRecentAge, pageUpdatePeriod: $pageUpdatePeriod, targetRowHeight: $targetRowHeight)'; +} + + +} + +/// @nodoc +abstract mixin class $QueueSettingsStateCopyWith<$Res> { + factory $QueueSettingsStateCopyWith(QueueSettingsState value, $Res Function(QueueSettingsState) _then) = _$QueueSettingsStateCopyWithImpl; +@useResult +$Res call({ + bool isTopScreen, int topOrdersCount, int shardNumber, int shardTotal, Duration maxShowAge, Duration maxRecentAge, Duration pageUpdatePeriod, double targetRowHeight +}); + + + + +} +/// @nodoc +class _$QueueSettingsStateCopyWithImpl<$Res> + implements $QueueSettingsStateCopyWith<$Res> { + _$QueueSettingsStateCopyWithImpl(this._self, this._then); + + final QueueSettingsState _self; + final $Res Function(QueueSettingsState) _then; + +/// Create a copy of QueueSettingsState +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? isTopScreen = null,Object? topOrdersCount = null,Object? shardNumber = null,Object? shardTotal = null,Object? maxShowAge = null,Object? maxRecentAge = null,Object? pageUpdatePeriod = null,Object? targetRowHeight = null,}) { + return _then(_self.copyWith( +isTopScreen: null == isTopScreen ? _self.isTopScreen : isTopScreen // ignore: cast_nullable_to_non_nullable +as bool,topOrdersCount: null == topOrdersCount ? _self.topOrdersCount : topOrdersCount // ignore: cast_nullable_to_non_nullable +as int,shardNumber: null == shardNumber ? _self.shardNumber : shardNumber // ignore: cast_nullable_to_non_nullable +as int,shardTotal: null == shardTotal ? _self.shardTotal : shardTotal // ignore: cast_nullable_to_non_nullable +as int,maxShowAge: null == maxShowAge ? _self.maxShowAge : maxShowAge // ignore: cast_nullable_to_non_nullable +as Duration,maxRecentAge: null == maxRecentAge ? _self.maxRecentAge : maxRecentAge // ignore: cast_nullable_to_non_nullable +as Duration,pageUpdatePeriod: null == pageUpdatePeriod ? _self.pageUpdatePeriod : pageUpdatePeriod // ignore: cast_nullable_to_non_nullable +as Duration,targetRowHeight: null == targetRowHeight ? _self.targetRowHeight : targetRowHeight // ignore: cast_nullable_to_non_nullable +as double, + )); +} + +} + + +/// Adds pattern-matching-related methods to [QueueSettingsState]. +extension QueueSettingsStatePatterns on QueueSettingsState { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _QueueSettingsState value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _QueueSettingsState() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _QueueSettingsState value) $default,){ +final _that = this; +switch (_that) { +case _QueueSettingsState(): +return $default(_that);} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _QueueSettingsState value)? $default,){ +final _that = this; +switch (_that) { +case _QueueSettingsState() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( bool isTopScreen, int topOrdersCount, int shardNumber, int shardTotal, Duration maxShowAge, Duration maxRecentAge, Duration pageUpdatePeriod, double targetRowHeight)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _QueueSettingsState() when $default != null: +return $default(_that.isTopScreen,_that.topOrdersCount,_that.shardNumber,_that.shardTotal,_that.maxShowAge,_that.maxRecentAge,_that.pageUpdatePeriod,_that.targetRowHeight);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( bool isTopScreen, int topOrdersCount, int shardNumber, int shardTotal, Duration maxShowAge, Duration maxRecentAge, Duration pageUpdatePeriod, double targetRowHeight) $default,) {final _that = this; +switch (_that) { +case _QueueSettingsState(): +return $default(_that.isTopScreen,_that.topOrdersCount,_that.shardNumber,_that.shardTotal,_that.maxShowAge,_that.maxRecentAge,_that.pageUpdatePeriod,_that.targetRowHeight);} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( bool isTopScreen, int topOrdersCount, int shardNumber, int shardTotal, Duration maxShowAge, Duration maxRecentAge, Duration pageUpdatePeriod, double targetRowHeight)? $default,) {final _that = this; +switch (_that) { +case _QueueSettingsState() when $default != null: +return $default(_that.isTopScreen,_that.topOrdersCount,_that.shardNumber,_that.shardTotal,_that.maxShowAge,_that.maxRecentAge,_that.pageUpdatePeriod,_that.targetRowHeight);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _QueueSettingsState extends QueueSettingsState { + const _QueueSettingsState({this.isTopScreen = false, this.topOrdersCount = 0, this.shardNumber = 1, this.shardTotal = 1, this.maxShowAge = const Duration(minutes: 15), this.maxRecentAge = const Duration(minutes: 5), this.pageUpdatePeriod = const Duration(seconds: 5), this.targetRowHeight = 100}): super._(); + factory _QueueSettingsState.fromJson(Map json) => _$QueueSettingsStateFromJson(json); + +/// If set to `true`, this screen will only show the first page and will +/// never paginate. +/// +/// If set to `false`, this screen works normally except it ignores +/// the first [topOrdersCount] of orders. +@override@JsonKey() final bool isTopScreen; +/// Asks the screen to ignore the first N orders (because, presumably, +/// they are shown on the top screen (see [isTopScreen]). +@override@JsonKey() final int topOrdersCount; +/// The shard number of this device. +@override@JsonKey() final int shardNumber; +/// The total number of shards in play. +@override@JsonKey() final int shardTotal; +/// The maximum age of a completed order, after which the order will not +/// be shown anymore. +@override@JsonKey() final Duration maxShowAge; +/// The duration for which an order is considered "recent". The UI may +/// choose to highlight such orders. +@override@JsonKey() final Duration maxRecentAge; +/// The duration between updates of the UI. +@override@JsonKey() final Duration pageUpdatePeriod; +/// A scaling factor for order rows. Can be customized in order to achieve +/// more or less dense displays (for example, when the display is farther +/// away from the customers than expected). +@override@JsonKey() final double targetRowHeight; + +/// Create a copy of QueueSettingsState +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$QueueSettingsStateCopyWith<_QueueSettingsState> get copyWith => __$QueueSettingsStateCopyWithImpl<_QueueSettingsState>(this, _$identity); + +@override +Map toJson() { + return _$QueueSettingsStateToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _QueueSettingsState&&(identical(other.isTopScreen, isTopScreen) || other.isTopScreen == isTopScreen)&&(identical(other.topOrdersCount, topOrdersCount) || other.topOrdersCount == topOrdersCount)&&(identical(other.shardNumber, shardNumber) || other.shardNumber == shardNumber)&&(identical(other.shardTotal, shardTotal) || other.shardTotal == shardTotal)&&(identical(other.maxShowAge, maxShowAge) || other.maxShowAge == maxShowAge)&&(identical(other.maxRecentAge, maxRecentAge) || other.maxRecentAge == maxRecentAge)&&(identical(other.pageUpdatePeriod, pageUpdatePeriod) || other.pageUpdatePeriod == pageUpdatePeriod)&&(identical(other.targetRowHeight, targetRowHeight) || other.targetRowHeight == targetRowHeight)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,isTopScreen,topOrdersCount,shardNumber,shardTotal,maxShowAge,maxRecentAge,pageUpdatePeriod,targetRowHeight); + +@override +String toString() { + return 'QueueSettingsState(isTopScreen: $isTopScreen, topOrdersCount: $topOrdersCount, shardNumber: $shardNumber, shardTotal: $shardTotal, maxShowAge: $maxShowAge, maxRecentAge: $maxRecentAge, pageUpdatePeriod: $pageUpdatePeriod, targetRowHeight: $targetRowHeight)'; +} + + +} + +/// @nodoc +abstract mixin class _$QueueSettingsStateCopyWith<$Res> implements $QueueSettingsStateCopyWith<$Res> { + factory _$QueueSettingsStateCopyWith(_QueueSettingsState value, $Res Function(_QueueSettingsState) _then) = __$QueueSettingsStateCopyWithImpl; +@override @useResult +$Res call({ + bool isTopScreen, int topOrdersCount, int shardNumber, int shardTotal, Duration maxShowAge, Duration maxRecentAge, Duration pageUpdatePeriod, double targetRowHeight +}); + + + + +} +/// @nodoc +class __$QueueSettingsStateCopyWithImpl<$Res> + implements _$QueueSettingsStateCopyWith<$Res> { + __$QueueSettingsStateCopyWithImpl(this._self, this._then); + + final _QueueSettingsState _self; + final $Res Function(_QueueSettingsState) _then; + +/// Create a copy of QueueSettingsState +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? isTopScreen = null,Object? topOrdersCount = null,Object? shardNumber = null,Object? shardTotal = null,Object? maxShowAge = null,Object? maxRecentAge = null,Object? pageUpdatePeriod = null,Object? targetRowHeight = null,}) { + return _then(_QueueSettingsState( +isTopScreen: null == isTopScreen ? _self.isTopScreen : isTopScreen // ignore: cast_nullable_to_non_nullable +as bool,topOrdersCount: null == topOrdersCount ? _self.topOrdersCount : topOrdersCount // ignore: cast_nullable_to_non_nullable +as int,shardNumber: null == shardNumber ? _self.shardNumber : shardNumber // ignore: cast_nullable_to_non_nullable +as int,shardTotal: null == shardTotal ? _self.shardTotal : shardTotal // ignore: cast_nullable_to_non_nullable +as int,maxShowAge: null == maxShowAge ? _self.maxShowAge : maxShowAge // ignore: cast_nullable_to_non_nullable +as Duration,maxRecentAge: null == maxRecentAge ? _self.maxRecentAge : maxRecentAge // ignore: cast_nullable_to_non_nullable +as Duration,pageUpdatePeriod: null == pageUpdatePeriod ? _self.pageUpdatePeriod : pageUpdatePeriod // ignore: cast_nullable_to_non_nullable +as Duration,targetRowHeight: null == targetRowHeight ? _self.targetRowHeight : targetRowHeight // ignore: cast_nullable_to_non_nullable +as double, + )); +} + + +} + +// dart format on diff --git a/genlatte/genlatte-ui/lib/src/screens/queue/home/queue_settings.g.dart b/genlatte/genlatte-ui/lib/src/screens/queue/home/queue_settings.g.dart new file mode 100644 index 0000000..363368e --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/screens/queue/home/queue_settings.g.dart @@ -0,0 +1,41 @@ +// Copyright 2014 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. + +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'queue_settings.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_QueueSettingsState _$QueueSettingsStateFromJson(Map json) => + _QueueSettingsState( + isTopScreen: json['isTopScreen'] as bool? ?? false, + topOrdersCount: (json['topOrdersCount'] as num?)?.toInt() ?? 0, + shardNumber: (json['shardNumber'] as num?)?.toInt() ?? 1, + shardTotal: (json['shardTotal'] as num?)?.toInt() ?? 1, + maxShowAge: json['maxShowAge'] == null + ? const Duration(minutes: 15) + : Duration(microseconds: (json['maxShowAge'] as num).toInt()), + maxRecentAge: json['maxRecentAge'] == null + ? const Duration(minutes: 5) + : Duration(microseconds: (json['maxRecentAge'] as num).toInt()), + pageUpdatePeriod: json['pageUpdatePeriod'] == null + ? const Duration(seconds: 5) + : Duration(microseconds: (json['pageUpdatePeriod'] as num).toInt()), + targetRowHeight: (json['targetRowHeight'] as num?)?.toDouble() ?? 100, + ); + +Map _$QueueSettingsStateToJson(_QueueSettingsState instance) => + { + 'isTopScreen': instance.isTopScreen, + 'topOrdersCount': instance.topOrdersCount, + 'shardNumber': instance.shardNumber, + 'shardTotal': instance.shardTotal, + 'maxShowAge': instance.maxShowAge.inMicroseconds, + 'maxRecentAge': instance.maxRecentAge.inMicroseconds, + 'pageUpdatePeriod': instance.pageUpdatePeriod.inMicroseconds, + 'targetRowHeight': instance.targetRowHeight, + }; diff --git a/genlatte/genlatte-ui/lib/src/screens/queue/queue.dart b/genlatte/genlatte-ui/lib/src/screens/queue/queue.dart new file mode 100644 index 0000000..5dbfdbd --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/screens/queue/queue.dart @@ -0,0 +1,5 @@ +// Copyright 2014 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 'home/queue_home.dart'; diff --git a/genlatte/genlatte-ui/lib/src/screens/queue/util/CONTEXT.md b/genlatte/genlatte-ui/lib/src/screens/queue/util/CONTEXT.md new file mode 100644 index 0000000..87fb928 --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/screens/queue/util/CONTEXT.md @@ -0,0 +1,13 @@ +# Queue Util + +**Purpose:** +Provides pure utility helper abstractions specific to the `queue` domain, specifically around logging formatting. + +**Detailed File Overviews:** + +- `metadata_describe.dart`: + - **Description**: An extension on `LatteOrderMetadata`. + - **Core Logic**: Exposes a `.describe()` method that formats a debugging string, appending a `-completed` suffix if the `completionTime` field is non-null. Widely used inside `queue_home_bloc` logging. + +**Dependencies/Relationships:** +- Imposed via Extension on `genlatte_data`'s `LatteOrderMetadata` class. diff --git a/genlatte/genlatte-ui/lib/src/screens/queue/util/metadata_describe.dart b/genlatte/genlatte-ui/lib/src/screens/queue/util/metadata_describe.dart new file mode 100644 index 0000000..040dd95 --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/screens/queue/util/metadata_describe.dart @@ -0,0 +1,16 @@ +// Copyright 2014 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:genlatte_data/models.dart'; + +extension MetadataDescribe on LatteOrderMetadata { + String describe() { + final buf = StringBuffer('<')..write(id); + if (completionTime != null) { + buf.write('-completed'); + } + buf.write('>'); + return buf.toString(); + } +} diff --git a/genlatte/genlatte-ui/lib/src/screens/queue/widgets/CONTEXT.md b/genlatte/genlatte-ui/lib/src/screens/queue/widgets/CONTEXT.md new file mode 100644 index 0000000..e51716c --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/screens/queue/widgets/CONTEXT.md @@ -0,0 +1,25 @@ +# Queue Widgets + +**Purpose:** +Provides complex UI components specifically built for the `queue` display screen. This includes the massive list view and its nested child items, as well as a specialized tool for configuring the display board's hardware installation properties. + +**Detailed File Overviews:** + +- `debug_overlay.dart`: + - **Description**: Defines `OrderBoardDebugOverlay`. + - **Core Logic**: An intense settings panel containing complex form inputs allowing field technicians to change hardware behavior without rebuilding the app. It modifies values managed by `QueueHomeBloc` like "Target row height", active Shards to compute modulo routing mathematics, and "Page update periods". It also contains fake data generator tools for offline mock testing. + +- `order_list.dart`: + - **Description**: The core container `OrderList` component mapping to multiple pages of orders. + - **Core Logic**: Employs `AnimationController` and `Timer` mechanics synchronized perfectly to the nearest clock-synchronous interval to compute autonomous Page flip transitions. It pre-caches Latte images exactly `1` second before flipping pages to avoid image flash pops. + +- `order_list_item.dart`: + - **Description**: An individual row in the queue representing a single customer order. + - **Core Logic**: Renders the specific completion status, customer name, and latte image. Handles complex staggering animation delays based on its index sequence (`positionInList`) during page flips. + +- `status_icon.dart`: + - **Description**: Converts the semantic `LatteOrderStatus` enum to animated icon combinations. + +**Dependencies/Relationships:** +- Used heavily by `queue/home/queue_home_view.dart`. +- Uses `shadcn_flutter` for core primitive forms. diff --git a/genlatte/genlatte-ui/lib/src/screens/queue/widgets/debug_overlay.dart b/genlatte/genlatte-ui/lib/src/screens/queue/widgets/debug_overlay.dart new file mode 100644 index 0000000..a0908eb --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/screens/queue/widgets/debug_overlay.dart @@ -0,0 +1,743 @@ +// Copyright 2014 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:math'; + +import 'package:genlatte/src/screens/app/app.dart'; +import 'package:genlatte/src/screens/queue/home/fake_repositories.dart'; +import 'package:genlatte/src/screens/queue/home/queue_home.dart'; +import 'package:genlatte/src/screens/queue/util/metadata_describe.dart'; +import 'package:genlatte_data/models.dart'; +import 'package:logging/logging.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +class OrderBoardDebugOverlay extends StatefulWidget { + const OrderBoardDebugOverlay({ + required this.bloc, + required this.ordersRepository, + required this.metadataRepository, + super.key, + }); + + final QueueHomeBloc bloc; + + /// The provided fake orders repository. + final FakeLatteOrdersRepository? ordersRepository; + + /// The provided fake metadata repository. + final FakeLatteOrdersMetadataRepository? metadataRepository; + + @override + State createState() => _OrderBoardDebugOverlayState(); +} + +class _CheckboxField extends StatefulWidget { + const _CheckboxField({ + required this.formKey, + required this.label, + required this.initialValue, + }); + + final FormKey formKey; + final String label; + final bool initialValue; + + @override + State<_CheckboxField> createState() => _CheckboxFieldState(); +} + +class _CheckboxFieldState extends State<_CheckboxField> { + late bool _state = widget.initialValue; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 8, + children: [ + FormField( + key: widget.formKey, + label: Text(widget.label), + child: Switch( + value: _state, + onChanged: (value) { + setState(() { + _state = value; + }); + }, + ), + ), + ], + ); + } +} + +class _NumberFieldWithButtons extends StatelessWidget { + const _NumberFieldWithButtons({ + required this.formKey, + required this.label, + required this.controller, + required this.step, + required this.fallbackValue, + this.trailingLabel, + }); + + final FormKey formKey; + final String label; + final String? trailingLabel; + final TextEditingController controller; + final double step; + final double fallbackValue; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 8, + children: [ + FormField( + key: formKey, + label: Text(label), + trailingLabel: trailingLabel != null ? Text(trailingLabel!) : null, + child: TextField( + controller: controller, + ), + ), + Row( + spacing: 8, + children: [ + Button( + onPressed: () { + final currentString = Form.of(context).getValue(formKey); + final current = double.tryParse(currentString ?? ''); + if (current != null) { + final val = current - step; + controller.text = val == val.toInt() + ? val.toInt().toString() + : val.toString(); + } else { + controller.text = fallbackValue == fallbackValue.toInt() + ? fallbackValue.toInt().toString() + : fallbackValue.toString(); + } + }, + style: const ButtonStyle.outlineIcon(), + child: const Text('-'), + ), + Button( + onPressed: () { + final currentString = Form.of(context).getValue(formKey); + final current = double.tryParse(currentString ?? ''); + if (current != null) { + final val = current + step; + controller.text = val == val.toInt() + ? val.toInt().toString() + : val.toString(); + } else { + controller.text = fallbackValue == fallbackValue.toInt() + ? fallbackValue.toInt().toString() + : fallbackValue.toString(); + } + }, + style: const ButtonStyle.outlineIcon(), + child: const Text('+'), + ), + ], + ), + ], + ); + } +} + +class _OrderBoardDebugOverlayState extends State { + static final Logger _logger = Logger('$_OrderBoardDebugOverlayState'); + + static int _orderIdCounter = 1; + + final _isTopScreenKey = const FormKey('is_top_screen'); + + final _topOrdersCountKey = const FormKey('top_orders_count'); + + final _maxRecentAgeKey = const FormKey('max_recent_age'); + + final _maxShowAgeKey = const FormKey('max_show_age'); + + final _shardNumberKey = const FormKey('shard_number'); + + final _shardTotalKey = const FormKey('shard_total'); + + final _pageUpdatePeriodKey = const FormKey('page_update_period'); + + final _targetRowHeightKey = const FormKey('target_row_height'); + + late final _topOrdersCountController = TextEditingController( + text: widget.bloc.state.settings.topOrdersCount.toString(), + ); + + late final _maxRecentAgeController = TextEditingController( + text: widget.bloc.state.settings.maxRecentAge.asMinutesString, + ); + + late final _maxShowAgeController = TextEditingController( + text: widget.bloc.state.settings.maxShowAge.asMinutesString, + ); + + late final _shardNumberController = TextEditingController( + text: widget.bloc.state.settings.shardNumber.toString(), + ); + + late final _shardTotalController = TextEditingController( + text: widget.bloc.state.settings.shardTotal.toString(), + ); + + late final _pageUpdatePeriodController = TextEditingController( + text: widget.bloc.state.settings.pageUpdatePeriod.asSecondsString, + ); + + late final _targetRowHeightController = TextEditingController( + text: widget.bloc.state.settings.targetRowHeight.toString(), + ); + + bool get _hasFakes => + widget.ordersRepository != null && widget.metadataRepository != null; + + @override + Widget build(BuildContext context) { + final settings = widget.bloc.state.settings; + + return ColoredBox( + color: AppColors.chevronYellow, + child: DefaultTextStyle( + style: const TextStyle(color: AppColors.black), + child: Padding( + padding: const EdgeInsetsGeometry.all(16), + child: Column( + mainAxisSize: .min, + crossAxisAlignment: .start, + spacing: 8, + children: [ + const Text('SETUP'), + Form( + onSubmit: (context, values) { + final isTopScreen = _isTopScreenKey[values]; + final topOrdersCount = int.tryParse( + _topOrdersCountKey[values] ?? '', + ); + + if (isTopScreen != null && topOrdersCount != null) { + widget.bloc.add( + SetTopSettings( + isTopScreen: isTopScreen, + topOrdersCount: topOrdersCount, + ), + ); + } else { + showToast( + context: context, + builder: (context, overlay) { + return SurfaceCard( + child: Basic( + title: const Text('Invalid top settings'), + subtitle: Text( + 'Top screen: ${_isTopScreenKey[values]}\n' + 'Top orders count: ${_topOrdersCountKey[values]}', + ), + ), + ); + }, + ); + } + }, + child: Column( + crossAxisAlignment: .end, + spacing: 8, + children: [ + Row( + crossAxisAlignment: .start, + spacing: 8, + children: [ + _CheckboxField( + formKey: _isTopScreenKey, + label: 'is top', + initialValue: widget.bloc.state.settings.isTopScreen, + ), + const SizedBox( + width: 16, + ), + _NumberFieldWithButtons( + formKey: _topOrdersCountKey, + label: 'top orders count', + controller: _topOrdersCountController, + step: 1, + fallbackValue: settings.topOrdersCount.toDouble(), + ), + ], + ), + FormErrorBuilder( + builder: (context, errors, child) => Button( + enableFeedback: true, + style: const ButtonStyle.outline(), + onPressed: errors.isEmpty + ? () => context.submitForm() + : null, + child: const Text('Save'), + ), + ), + ], + ), + ), + Form( + onSubmit: (context, values) { + final shardNumber = int.tryParse( + _shardNumberKey[values] ?? '', + ); + final shardTotal = int.tryParse(_shardTotalKey[values] ?? ''); + + if (shardNumber != null && shardTotal != null) { + widget.bloc.add(SetShard(shardNumber, shardTotal)); + } else { + showToast( + context: context, + builder: (context, overlay) { + return SurfaceCard( + child: Basic( + title: const Text('Invalid shard setup'), + subtitle: Text( + 'Shard #: ${_shardNumberKey[values]}\n' + 'Shard total: ${_shardTotalKey[values]}', + ), + ), + ); + }, + ); + } + }, + child: Column( + crossAxisAlignment: .end, + spacing: 8, + children: [ + Row( + crossAxisAlignment: .start, + spacing: 8, + children: [ + _NumberFieldWithButtons( + formKey: _shardNumberKey, + label: 'shard #', + controller: _shardNumberController, + step: 1, + fallbackValue: settings.shardNumber.toDouble(), + ), + const Padding( + padding: EdgeInsets.only(top: 32), + child: Text(' of '), + ), + _NumberFieldWithButtons( + formKey: _shardTotalKey, + label: 'shards total', + controller: _shardTotalController, + step: 1, + fallbackValue: settings.shardTotal.toDouble(), + ), + ], + ), + FormErrorBuilder( + builder: (context, errors, child) => Button( + enableFeedback: true, + style: const ButtonStyle.outline(), + onPressed: errors.isEmpty + ? () => context.submitForm() + : null, + child: const Text('Save'), + ), + ), + ], + ), + ), + Form( + onSubmit: (context, values) { + final pageUpdatePeriod = double.tryParse( + _pageUpdatePeriodKey[values] ?? '', + ); + + if (pageUpdatePeriod != null) { + widget.bloc.add( + SetPageUpdatePeriod( + Duration( + milliseconds: + (pageUpdatePeriod * + Duration.millisecondsPerSecond) + .round(), + ), + ), + ); + } else { + showToast( + context: context, + builder: (context, overlay) { + return SurfaceCard( + child: Basic( + title: const Text( + 'Invalid page update period setup', + ), + subtitle: Text( + 'Page update period: ' + '${_pageUpdatePeriodKey[values]}', + ), + ), + ); + }, + ); + } + }, + child: Column( + crossAxisAlignment: .end, + spacing: 8, + children: [ + Row( + crossAxisAlignment: .start, + spacing: 8, + children: [ + _NumberFieldWithButtons( + formKey: _pageUpdatePeriodKey, + label: 'Page update period', + trailingLabel: 'in seconds', + controller: _pageUpdatePeriodController, + step: 1, + fallbackValue: double.parse( + settings.pageUpdatePeriod.asSecondsString, + ), + ), + ], + ), + FormErrorBuilder( + builder: (context, errors, child) => Button( + enableFeedback: true, + style: const ButtonStyle.outline(), + onPressed: errors.isEmpty + ? () => context.submitForm() + : null, + child: const Text('Save'), + ), + ), + ], + ), + ), + Form( + onSubmit: (context, values) { + final targetRowHeight = double.tryParse( + _targetRowHeightKey[values] ?? '', + ); + + if (targetRowHeight != null) { + widget.bloc.add(SetTargetRowHeight(targetRowHeight)); + } else { + showToast( + context: context, + builder: (context, overlay) { + return SurfaceCard( + child: Basic( + title: const Text('Invalid target row height'), + subtitle: Text( + 'Height: ${_targetRowHeightKey[values]}', + ), + ), + ); + }, + ); + } + }, + child: Column( + crossAxisAlignment: .end, + spacing: 8, + children: [ + Row( + crossAxisAlignment: .start, + children: [ + _NumberFieldWithButtons( + formKey: _targetRowHeightKey, + label: 'Target row height', + trailingLabel: 'in pixels', + controller: _targetRowHeightController, + step: 10, + fallbackValue: settings.targetRowHeight, + ), + ], + ), + FormErrorBuilder( + builder: (context, errors, child) => Button( + enableFeedback: true, + style: const ButtonStyle.outline(), + onPressed: errors.isEmpty + ? () => context.submitForm() + : null, + child: const Text('Save'), + ), + ), + ], + ), + ), + + Form( + onSubmit: (context, values) { + final maxRecentAge = double.tryParse( + _maxRecentAgeKey[values] ?? '', + ); + final maxShowAge = double.tryParse( + _maxShowAgeKey[values] ?? '', + ); + + if (maxRecentAge != null && maxShowAge != null) { + widget.bloc.add( + SetRecency( + Duration( + seconds: (maxRecentAge * Duration.secondsPerMinute) + .round(), + ), + Duration( + seconds: (maxShowAge * Duration.secondsPerMinute) + .round(), + ), + ), + ); + } else { + showToast( + context: context, + builder: (context, overlay) { + return SurfaceCard( + child: Basic( + title: const Text('Invalid recency setup'), + subtitle: Text( + 'Max recent age: ${_maxRecentAgeKey[values]}\n' + 'Max show age: ${_maxShowAgeKey[values]}', + ), + ), + ); + }, + ); + } + }, + child: Column( + crossAxisAlignment: .end, + spacing: 8, + children: [ + Row( + crossAxisAlignment: .start, + spacing: 8, + children: [ + _NumberFieldWithButtons( + formKey: _maxRecentAgeKey, + label: 'Max recent age', + trailingLabel: 'in minutes', + controller: _maxRecentAgeController, + step: 1, + fallbackValue: double.parse( + settings.maxRecentAge.asMinutesString, + ), + ), + _NumberFieldWithButtons( + formKey: _maxShowAgeKey, + label: 'Max show age', + trailingLabel: 'in minutes', + controller: _maxShowAgeController, + step: 1, + fallbackValue: double.parse( + settings.maxShowAge.asMinutesString, + ), + ), + ], + ), + FormErrorBuilder( + builder: (context, errors, child) => Button( + enableFeedback: true, + style: const ButtonStyle.outline(), + onPressed: errors.isEmpty + ? () => context.submitForm() + : null, + child: const Text('Save'), + ), + ), + ], + ), + ), + + const Text('DEBUG'), + + Button( + enableFeedback: true, + style: const ButtonStyle.outline(), + onPressed: _hasFakes + ? () async { + _logger.fine( + 'Add order pressed', + ); + final id = _orderIdCounter++; + + await widget.ordersRepository!.setItem( + LatteOrder( + id: '$id', + name: const [ + 'Alice', + 'Bob', + 'Clarence', + 'Daphne', + 'Eve', + 'Frank', + 'Grace', + 'Heidi', + 'Ivan', + 'Judy', + 'Karl', + 'Leo', + 'Mallory', + 'Nina', + ][id % 14], + milk: 'nope', + sweetener: 'yes', + happyPlace: 'home', + ), + ); + await widget.metadataRepository!.setItem( + LatteOrderMetadata( + id: '$id', + orderNumber: id, + isNameApproved: true, + isHappyPlaceApproved: true, + isImageApproved: true, + imageBatchId: 'image-batch-$id', + imageUrl: null, + status: .submitted, + orderSubmittedTime: DateTime.now(), + ), + ); + } + : null, + + child: const Text('Add order'), + ), + Button( + enableFeedback: true, + style: const ButtonStyle.outline(), + onPressed: _hasFakes + ? () async { + _logger.fine('Claim random order pressed'); + + final allItems = + (await widget.metadataRepository!.getItems()) + .where( + (o) => + o.status != .completed && + o.status != .inProgress && + o.status != .archived, + ) + .toList(); + if (allItems.isEmpty) return; + + final random = Random(); + final randomItem = + allItems[random.nextInt(allItems.length)]; + _logger.fine( + 'Picked: ${randomItem.describe()}', + ); + + final modified = randomItem.copyWith( + status: .inProgress, + ); + _logger.fine('Modified: ${modified.describe()}'); + + await widget.metadataRepository!.setItem(modified); + } + : null, + child: const Text('Claim random order'), + ), + Button( + enableFeedback: true, + style: const ButtonStyle.outline(), + onPressed: _hasFakes + ? () async { + _logger.fine('Complete random order pressed'); + + final allItems = + (await widget.metadataRepository!.getItems()) + .where((o) => o.status == .inProgress) + .toList(); + if (allItems.isEmpty) return; + + final random = Random(); + final randomItem = + allItems[random.nextInt(allItems.length)]; + _logger.fine( + 'Picked: ${randomItem.describe()}', + ); + + final modified = randomItem.copyWith( + completionTime: DateTime.now(), + status: .completed, + ); + _logger.fine('Modified: ${modified.describe()}'); + + await widget.metadataRepository!.setItem(modified); + } + : null, + child: const Text('Complete random order'), + ), + Button( + enableFeedback: true, + style: const ButtonStyle.outline(), + onPressed: _hasFakes + ? () async { + _logger.fine('Archive random order pressed'); + + var allItems = + (await widget.metadataRepository!.getItems()) + .where((o) => o.status == .completed) + .toList(); + if (allItems.isEmpty) { + allItems = + (await widget.metadataRepository!.getItems()) + .where((o) => o.status != .archived) + .toList(); + } + if (allItems.isEmpty) return; + + final random = Random(); + final randomItem = + allItems[random.nextInt(allItems.length)]; + _logger.fine('Picked: ${randomItem.describe()}'); + + final modified = randomItem.copyWith( + completionTime: DateTime.now().subtract( + const Duration(days: 14), + ), + status: .archived, + ); + _logger.fine('Modified: ${modified.describe()}'); + + await widget.metadataRepository!.setItem(modified); + } + : null, + child: const Text('Archive random order'), + ), + ], + ), + ), + ), + ); + } + + @override + void dispose() { + _maxRecentAgeController.dispose(); + _maxShowAgeController.dispose(); + _shardNumberController.dispose(); + _shardTotalController.dispose(); + _pageUpdatePeriodController.dispose(); + _targetRowHeightController.dispose(); + super.dispose(); + } +} + +extension on Duration { + String get asMinutesString => (inSeconds / 60).toString(); + String get asSecondsString => (inMilliseconds / 1000).toString(); +} diff --git a/genlatte/genlatte-ui/lib/src/screens/queue/widgets/order_list.dart b/genlatte/genlatte-ui/lib/src/screens/queue/widgets/order_list.dart new file mode 100644 index 0000000..fac394f --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/screens/queue/widgets/order_list.dart @@ -0,0 +1,426 @@ +// Copyright 2014 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:math'; + +import 'package:firebase_auth/firebase_auth.dart'; +import 'package:genlatte/src/screens/app/app.dart'; +import 'package:genlatte/src/screens/queue/home/queue_home.dart'; +import 'package:genlatte/src/screens/queue/widgets/order_list_item.dart'; +import 'package:genlatte/src/widgets/triple_tap.dart'; +import 'package:genlatte/src/widgets/typography.dart'; +import 'package:get_it/get_it.dart'; +import 'package:logging/logging.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +class OrderList extends StatefulWidget { + const OrderList({ + required this.pages, + required this.onSlotsCounted, + required this.maxRecentAge, + required this.targetRowHeight, + required this.noOrdersInAnyShard, + required this.pageUpdatePeriod, + super.key, + }); + + /// Image used for latte orders whose images aren't approved (yet). + /// + /// Follows the example of + /// https://github.com/GoogleCloudDemos/gcdemos-26-int-dd-latteart/pull/197. + static const fallbackImageUrl = + 'https://firebasestorage.googleapis.com/v0/b/gcdemos-26-int-dd-latteart.firebasestorage.app/o/latteImages%2FfallbackImage%2Ficon_flutter.png?alt=media&token=06a3ac7e-7929-4507-8105-9eddb4445892'; + + final List pages; + + final void Function(int) onSlotsCounted; + + final Duration maxRecentAge; + + final Duration pageUpdatePeriod; + + final double targetRowHeight; + + final bool noOrdersInAnyShard; + + @override + State createState() => _OrderListState(); +} + +class _OrderListState extends State + with SingleTickerProviderStateMixin { + static final Logger _logger = Logger('$_OrderListState'); + + /// The amount of time before every tick when we preload images. + static const Duration _preloadDuration = Duration(seconds: 1); + + /// The length of the transition of each row in the list. + static const Duration _rowTransitionDuration = Duration(milliseconds: 250); + + late final _transitionController = AnimationController(vsync: this); + + /// The tick that fires every N seconds since midnight. This ensures that + /// multiple displays all update at the same time. + Timer? _tickTimer; + + /// To prevent ugly image loading flashes, we preload images + /// a small amount of time ([_preloadDuration]) before every tick. + Timer? _preloadTimer; + + int currentPageIndex = 0; + + List currentPages = const []; + + List nextPages = const []; + + @override + Widget build(BuildContext context) { + return _Padding( + child: Column( + crossAxisAlignment: .start, + children: [ + Row( + children: [ + TripleTapDetector( + semanticLabel: 'Screen headline', + semanticHint: 'Triple tap to log out', + onPressed: () => GetIt.I().signOut(), + child: const Text('Order progress').h3, + ), + const Spacer(), + _PageIndicator( + currentPageIndex: currentPageIndex, + pageOrderCounts: currentPages + .map((p) => p.orders.nonNulls.length) + .toList(growable: false), + ), + ], + ), + const SizedBox(height: 14), + const ColoredBox( + color: AppColors.placeholderGrey, + child: SizedBox(width: double.infinity, height: 6), + ), + Expanded( + child: LayoutBuilder( + builder: (context, constraints) { + // TODO(filiph): add a heuristic that selects the best height + // according to orientation + final slotsAvailable = + (constraints.maxHeight / widget.targetRowHeight).floor(); + widget.onSlotsCounted(slotsAvailable); + + if (currentPageIndex >= currentPages.length) { + if (widget.noOrdersInAnyShard) { + return Center( + child: Column( + mainAxisAlignment: .center, + spacing: 16, + children: [ + const Text.rich( + TextSpan( + children: [ + TextSpan(text: 'Yes, you '), + TextSpan( + text: 'need', + style: TextStyle(decoration: .underline), + ), + TextSpan(text: ' coffee.'), + ], + ), + ).h2_, + const Text('Waiting for the first order.'), + ], + ), + ); + } else { + return const SizedBox.expand(); + } + } + final page = currentPages[currentPageIndex]; + + return Column( + crossAxisAlignment: .start, + children: [ + for (final (index, item) in page.orders.indexed) + Expanded( + key: Key('position-$index'), + child: OrderListItem( + item, + positionInList: index, + listLength: page.orders.length, + transitionAnimation: _transitionController, + animationDuration: _rowTransitionDuration, + maxRecentAge: widget.maxRecentAge, + addBottomDivider: + item != null && index < page.orders.length - 1, + ), + ), + ], + ); + }, + ), + ), + const ColoredBox( + color: AppColors.placeholderGrey, + child: SizedBox( + width: double.infinity, + height: 6, + ), + ), + ], + ), + ); + } + + @override + void didUpdateWidget(covariant OrderList oldWidget) { + super.didUpdateWidget(oldWidget); + + if (widget.pages != nextPages) { + _logger.fine('nextPages updated in didUpdateWidget()'); + nextPages = widget.pages; + } + } + + @override + void dispose() { + _tickTimer?.cancel(); + _preloadTimer?.cancel(); + _transitionController.dispose(); + super.dispose(); + } + + @override + void initState() { + super.initState(); + + currentPages = widget.pages; + nextPages = widget.pages; + + _startTransitionAnimation(); + _startTickTimer(); + } + + void _preloadImages() { + if (!mounted) { + _logger.info('_preloadImages() called after widget unmount, skipping'); + return; + } + + // Extract image urls from the orders that are going to be shown next. + // It's still possible for a new order to show up between now and the next + // tick, but the chances are low. + final urls = nextPages + .expand((page) => page.orders) + .nonNulls + .map( + (o) => + o.metadata.isImageApproved == true ? o.metadata.imageUrl : null, + ) + .nonNulls + .followedBy(const [OrderList.fallbackImageUrl]); + + for (final imageUrl in urls) { + precacheImage( + NetworkImage(imageUrl), + context, + onError: (e, s) => _logger.fine('Error when precaching image', e, s), + ).then((_) => _logger.finest('Image preloaded: $imageUrl')).ignore(); + } + + precacheImage( + const AssetImage('assets/latte-background-thumb.png'), + context, + onError: (e, s) => + _logger.fine('Error when precaching latte background image', e, s), + ).then((_) => _logger.finest('Latte background image preloaded')).ignore(); + } + + void _startTickTimer() { + final now = DateTime.now(); + final midnight = DateTime(now.year, now.month, now.day); + final timeInMs = now.difference(midnight).inMilliseconds; + final periodInMs = widget.pageUpdatePeriod.inMilliseconds; + final nextBoundaryInMs = + // Round up. + (timeInMs / periodInMs).ceil() * periodInMs; + final delayMs = nextBoundaryInMs - timeInMs; + final delay = Duration(milliseconds: delayMs); + + _tickTimer?.cancel(); + _tickTimer = Timer(delay, _tick); + + _preloadTimer?.cancel(); + if (delay > _preloadDuration) { + // We have time to schedule preloading, too. + _preloadTimer = Timer(delay - _preloadDuration, _preloadImages); + } + } + + void _startTransitionAnimation() { + if (currentPages.isEmpty) return; + final listLength = currentPages[currentPageIndex].orders.length; + _transitionController.duration = _rowTransitionDuration * listLength; + unawaited(_transitionController.forward(from: 0)); + } + + /// Each tick, we either go to the next page of [currentPages], + /// or (if all pages have been shown), we switch to [nextPages] a start anew. + /// + /// Called periodically via [_tickTimer]. + void _tick() { + if (!mounted) { + _logger.info('_tick() called after widget unmount, skipping'); + return; + } + + final bool hasFinishedWithCurrentPages; + if (currentPages.isEmpty) { + hasFinishedWithCurrentPages = true; + } else { + hasFinishedWithCurrentPages = currentPageIndex >= currentPages.length - 1; + } + + if (hasFinishedWithCurrentPages) { + setState(() { + currentPageIndex = 0; + currentPages = nextPages; + }); + _logger.fine( + 'Switching to new list of pages, ' + 'showing page 1 of ${currentPages.length}', + ); + } else { + setState(() => currentPageIndex += 1); + _logger.fine( + 'Showing page ${currentPageIndex + 1} ' + 'of ${currentPages.length}', + ); + } + + _startTransitionAnimation(); + _startTickTimer(); + } +} + +class _Padding extends StatelessWidget { + const _Padding({required this.child}); + + final Widget child; + + @override + Widget build(BuildContext context) { + return DecoratedBox( + decoration: BoxDecoration( + border: Border.all(color: AppColors.placeholderGrey, width: 16), + ), + child: Padding( + padding: const EdgeInsets.fromLTRB(64, 40, 64, 57), + child: child, + ), + ); + } +} + +class _PageIndicator extends StatelessWidget { + const _PageIndicator({ + required this.pageOrderCounts, + required this.currentPageIndex, + }); + + static const int minPages = 3; + + static const double width = 40; + + static const double height = 30; + + final List pageOrderCounts; + + final int currentPageIndex; + + @override + Widget build(BuildContext context) { + if (pageOrderCounts.isEmpty) { + return const SizedBox(width: minPages * width, height: height); + } + + final pageCount = pageOrderCounts.length; + final orderCountMax = pageOrderCounts.fold(0, max); + + return SizedBox( + width: max(pageOrderCounts.length, minPages) * width, + height: height, + child: Stack( + fit: .expand, + children: [ + for (final (index, count) in pageOrderCounts.indexed) + Positioned( + right: (pageCount - index - 1) * width, + width: width, + top: 0, + bottom: 0, + child: _PageIndicatorIcon( + orderCount: count, + orderCountMax: orderCountMax, + width: width, + ), + ), + AnimatedPositioned( + right: (pageCount - currentPageIndex - 1) * width, + width: width, + top: 0, + bottom: 0, + duration: const Duration(milliseconds: 1000), + curve: Curves.easeInOut, + child: DecoratedBox( + decoration: BoxDecoration( + border: Border.all(color: AppColors.placeholderGrey, width: 2), + ), + ), + ), + ], + ), + ); + } +} + +class _PageIndicatorIcon extends StatelessWidget { + const _PageIndicatorIcon({ + required this.orderCountMax, + required this.width, + required this.orderCount, + }); + + final int orderCount; + + final int orderCountMax; + + final double width; + + @override + Widget build(BuildContext context) { + return SizedBox( + width: width, + child: Padding( + padding: const EdgeInsets.all(4), + child: Column( + crossAxisAlignment: .stretch, + spacing: 1, + children: [ + for (var i = 0; i < orderCountMax; i++) + Expanded( + child: ColoredBox( + color: i < orderCount + ? AppColors.placeholderGrey + : AppColors.transparent, + ), + ), + ], + ), + ), + ); + } +} diff --git a/genlatte/genlatte-ui/lib/src/screens/queue/widgets/order_list_item.dart b/genlatte/genlatte-ui/lib/src/screens/queue/widgets/order_list_item.dart new file mode 100644 index 0000000..881e7c8 --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/screens/queue/widgets/order_list_item.dart @@ -0,0 +1,377 @@ +// Copyright 2014 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:genlatte/src/screens/app/app.dart'; +import 'package:genlatte/src/screens/queue/widgets/order_list.dart'; +import 'package:genlatte/src/screens/queue/widgets/status_icon.dart'; +import 'package:genlatte/src/widgets/latte_image.dart'; +import 'package:genlatte_data/models.dart'; +import 'package:logging/logging.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +class OrderListItem extends StatefulWidget { + const OrderListItem( + this.latte, { + required this.positionInList, + required this.listLength, + required this.transitionAnimation, + required this.animationDuration, + required this.maxRecentAge, + required this.addBottomDivider, + super.key, + }); + + final Latte? latte; + + final int positionInList; + + final int listLength; + + final Animation transitionAnimation; + + final Duration animationDuration; + + final Duration maxRecentAge; + + final bool addBottomDivider; + + @override + State createState() => _OrderListItemState(); +} + +class _OrderListItemState extends State { + static final Logger _log = Logger('$_OrderListItemState'); + + /// Keeps ahold of the latest order we showed so that we can properly + /// transition to whatever is the current one. + Latte? _previous; + + bool _previouslyNeededDivider = false; + + @override + Widget build(BuildContext context) { + final current = widget.latte; + + final animateIn = widget.transitionAnimation.drive( + // Animating from the bottom up (the later the position, the sooner + // we start animating). + // + // The sequence goes something like this, assuming this is order 2 of 4. + // + // 1 ___________________ + // ___/ + // ___/ + // 0 _________/ + // ^ ^ ^ ^ ^ + // Start Order 2 Order 3 Order 4 End + TweenSequence([ + if (widget.positionInList < widget.listLength - 1) + TweenSequenceItem( + tween: ConstantTween(0), + weight: widget.listLength - widget.positionInList - 1, + ), + TweenSequenceItem( + tween: Tween(begin: 0, end: 1), + weight: 1, + ), + if (widget.positionInList > 0) + TweenSequenceItem( + tween: ConstantTween(1), + weight: widget.positionInList.toDouble(), + ), + ]), + ); + late final animateOut = ReverseAnimation(animateIn); + + final now = DateTime.now(); + + Widget widgetForLatte(Latte latte, bool needsDivider) { + final String text; + if (latte.metadata.isImageApproved == true && latte.order.name != null) { + text = latte.order.name!; + } else { + final number = latte.metadata.orderNumber; + if (number != null) { + text = 'Order #$number'; + } else { + text = 'Mysterious order'; + } + } + + final OrderStatus state; + final submittedTime = latte.metadata.orderSubmittedTime; + final completionTime = latte.metadata.completionTime; + final sinceCompletion = completionTime != null + ? now.difference(completionTime) + : null; + if (sinceCompletion != null && sinceCompletion > widget.maxRecentAge) { + state = .completed; + } else if (sinceCompletion != null) { + state = .recentlyCompleted; + } else if (latte.metadata.status == .inProgress) { + state = .atBarista; + } else if (submittedTime != null) { + state = .visible; + } else { + _log.warning( + 'Non-submitted item somehow made it into the queue: $latte', + ); + return const SizedBox(); + } + + return Column( + children: [ + Expanded( + child: _OrderWidget( + text: text, + imageUrl: latte.metadata.isImageApproved == true + ? latte.metadata.imageUrl + : null, + status: state, + ), + ), + ColoredBox( + color: needsDivider + ? AppColors.placeholderGrey + : AppColors.transparent, + child: const SizedBox( + width: double.infinity, + height: 3, + ), + ), + ], + ); + } + + // TODO(filiph): this widget should try to have more stable children + // especially if we're going from same id to same id + return switch ((_previous, current)) { + (null, null) => const SizedBox.expand(), + (final Latte previous, null) => SlideTransition( + position: + Tween( + begin: const Offset(0, -0.1), + end: Offset.zero, + ).animate( + CurvedAnimation( + parent: animateOut, + curve: Curves.easeOutCubic, + ), + ), + child: FadeTransition( + opacity: CurvedAnimation( + parent: animateOut, + curve: Curves.easeInCubic, + ), + child: widgetForLatte(previous, _previouslyNeededDivider), + ), + ), + (null, final Latte current) => SlideTransition( + position: + Tween( + begin: const Offset(0, 0.1), + end: Offset.zero, + ).animate( + CurvedAnimation( + parent: animateIn, + curve: Curves.easeOutCubic, + ), + ), + child: FadeTransition( + opacity: animateIn, + child: widgetForLatte(current, widget.addBottomDivider), + ), + ), + (final Latte previous, final Latte current) => + (previous.order.id == current.order.id) + // A subtle fade when we're just updating an order. + ? Stack( + fit: .passthrough, + children: [ + FadeTransition( + opacity: CurvedAnimation( + parent: animateOut, + curve: Curves.easeOut, + ), + child: widgetForLatte(previous, _previouslyNeededDivider), + ), + FadeTransition( + opacity: animateIn, + child: widgetForLatte(current, widget.addBottomDivider), + ), + ], + ) + // A proper fly in / fly out when changing orders. + : Stack( + fit: .passthrough, + children: [ + FadeTransition( + opacity: CurvedAnimation( + parent: animateOut, + curve: Curves.easeIn, + ), + child: widgetForLatte(previous, _previouslyNeededDivider), + ), + SlideTransition( + position: + Tween( + begin: const Offset(0, 0.05), + end: Offset.zero, + ).animate( + CurvedAnimation( + parent: animateIn, + curve: Curves.easeOut, + ), + ), + child: FadeTransition( + opacity: animateIn, + child: widgetForLatte(current, widget.addBottomDivider), + ), + ), + ], + ), + }; + } + + @override + void didUpdateWidget(covariant OrderListItem oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.transitionAnimation != widget.transitionAnimation) { + oldWidget.transitionAnimation.removeStatusListener( + _onAnimationStatusChange, + ); + widget.transitionAnimation.addStatusListener(_onAnimationStatusChange); + } + } + + @override + void dispose() { + widget.transitionAnimation.removeStatusListener(_onAnimationStatusChange); + super.dispose(); + } + + @override + void initState() { + super.initState(); + widget.transitionAnimation.addStatusListener(_onAnimationStatusChange); + } + + void _onAnimationStatusChange(AnimationStatus status) { + if (status == .completed) { + _previous = widget.latte; + _previouslyNeededDivider = widget.addBottomDivider; + } + } +} + +class _OrderWidget extends StatelessWidget { + const _OrderWidget({ + required this.text, + required this.imageUrl, + required this.status, + }); + + final String text; + + final String? imageUrl; + + final OrderStatus status; + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + // The available height functions as a "unit of measurement" + // so that the proportions of the design are constant no matter + // how big or small the layout is. + final height = constraints.maxHeight; + + return Row( + children: [ + SizedBox( + width: height * 0.64, + child: imageUrl == null + ? const LatteImageWidget( + imageUrl: OrderList.fallbackImageUrl, + topScaleRatio: 0.50, + thumbnailSize: true, + ) + : LatteImageWidget( + // Adding a key here makes sure the circle avatar + // gets replaced as soon as the image url changes. + // Otherwise, there's a short glitch when new image shows + // up before the transition. + key: Key(imageUrl!), + imageUrl: imageUrl!, + thumbnailSize: true, + ), + ), + SizedBox(width: height * 0.20), + Expanded( + child: WrappedText( + style: (context, theme) => theme.typography.h1.copyWith( + fontSize: height * 0.44, + ), + child: Text( + text, + maxLines: 1, + overflow: .ellipsis, + ), + // TODO(filiph): This should say "Your latte is ready!" + // when recentlyCompleted or above. + ), + ), + if (status == .recentlyCompleted || status == .completed) + const Text('Your latte is ready!').h3 + else + const SizedBox.shrink(), + SizedBox( + width: height * 0.36, + ), + StatusIcon( + icon: Symbols.schedule, + size: height * 0.52, + status: switch (status) { + .visible => .active, + .atBarista => .done, + .recentlyCompleted => .done, + .completed => .done, + }, + ), + SizedBox( + width: height * 0.26, + ), + StatusIcon( + icon: Icons.image, + size: height * 0.52, + status: switch (status) { + .visible => .notYet, + .atBarista => .active, + .recentlyCompleted => .done, + .completed => .done, + }, + ), + SizedBox( + width: height * 0.26, + ), + StatusIcon( + icon: Icons.local_cafe, + size: height * 0.52, + status: switch (status) { + .visible => .notYet, + .atBarista => .notYet, + .recentlyCompleted => .active, + .completed => .done, + }, + ), + SizedBox( + width: height * 0.06, + ), + ], + ); + }, + ); + } +} diff --git a/genlatte/genlatte-ui/lib/src/screens/queue/widgets/status_icon.dart b/genlatte/genlatte-ui/lib/src/screens/queue/widgets/status_icon.dart new file mode 100644 index 0000000..40fd547 --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/screens/queue/widgets/status_icon.dart @@ -0,0 +1,140 @@ +// Copyright 2014 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:math'; +import 'dart:ui' as ui; + +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:genlatte/src/screens/app/app.dart'; + +enum IconStatus { notYet, active, done } + +enum OrderStatus { visible, atBarista, recentlyCompleted, completed } + +class StatusIcon extends StatefulWidget { + const StatusIcon({ + required this.status, + required this.icon, + required this.size, + super.key, + }); + + static const iconFill = 1.0; + + static const iconWeight = 400.0; + + static const iconOpticalSize = 24.0; + + static const iconGrade = 0.0; + + static const notYetColor = AppColors.placeholderGrey; + + static const activeColor = AppColors.googleIntroBlue; + + static const doneColor = AppColors.black; + + final IconStatus status; + final IconData icon; + final double size; + + @override + State createState() => _StatusIconState(); +} + +class _StatusIconState extends State + with SingleTickerProviderStateMixin { + static ui.Image? _textureImage; + + static Future? _loadFuture; + + @override + Widget build(BuildContext context) { + final color = switch (widget.status) { + IconStatus.notYet => StatusIcon.notYetColor, + IconStatus.active => StatusIcon.activeColor, + IconStatus.done => StatusIcon.doneColor, + }; + + final isTexturedActive = + widget.status == IconStatus.active && _textureImage != null; + + final baseIcon = Icon( + widget.icon, + size: widget.size, + fill: StatusIcon.iconFill, + weight: StatusIcon.iconWeight, + grade: StatusIcon.iconGrade, + opticalSize: StatusIcon.iconOpticalSize, + color: isTexturedActive ? const Color(0xFFFFFFFF) : color, + ); + + if (!isTexturedActive) { + return baseIcon; + } + + return ShaderMask( + blendMode: BlendMode.srcIn, + shaderCallback: (bounds) { + final texture = _textureImage!; + + final imageCenterX = texture.width / 2; + final imageCenterY = texture.height / 2; + final boundsDiagonal = sqrt( + bounds.width * bounds.width + bounds.height * bounds.height, + ); + final scaleFactor = boundsDiagonal / min(texture.width, texture.height); + + final matrix = Matrix4.identity() + ..translateByDouble(bounds.center.dx, bounds.center.dy, 0, 1) + //..rotateZ(-animationValue * 2 * pi) + ..scaleByDouble(scaleFactor, scaleFactor, 1, 1) + ..translateByDouble(-imageCenterX, -imageCenterY, 0, 1); + + return ImageShader( + texture, + TileMode.decal, + TileMode.decal, + matrix.storage, + ); + }, + child: baseIcon, + ); + } + + @override + void didUpdateWidget(StatusIcon oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.status != oldWidget.status) { + if (widget.status == IconStatus.active) { + unawaited(_ensureTextureLoaded()); + } + } + } + + @override + void initState() { + super.initState(); + if (widget.status == IconStatus.active) { + unawaited(_ensureTextureLoaded()); + } + } + + Future _ensureTextureLoaded() async { + if (_textureImage != null) return; + + _loadFuture ??= () async { + final data = await rootBundle.load( + 'assets/io-primary-gradient-mesh-small.jpg', + ); + final codec = await ui.instantiateImageCodec(data.buffer.asUint8List()); + final frame = await codec.getNextFrame(); + _textureImage = frame.image; + }(); + + await _loadFuture; + setState(() {}); + } +} diff --git a/genlatte/genlatte-ui/lib/src/screens/recent_orders/CONTEXT.md b/genlatte/genlatte-ui/lib/src/screens/recent_orders/CONTEXT.md new file mode 100644 index 0000000..04df99c --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/screens/recent_orders/CONTEXT.md @@ -0,0 +1,11 @@ +# Kiosk / Barista / Moderator / Queue / Recent Orders Roots + +**Purpose:** +This directory acts as the architectural boundary grouping for the application's distinct personas. + +**Implementation Details:** +- **Barrel Architecture**: This level strictly exports child module directories like `home/` and `widgets/` via an `index` / `barrel` file. +- **Child Contexts**: Please navigate into `home/` or `widgets/` subdirectories to view specific BLoC architectures, View layouts, and complex logic files tailored towards each app persona. + +**Dependencies:** +- Consumed by `core/routing/routes.dart` to map physical URLs to specific screen entry points. diff --git a/genlatte/genlatte-ui/lib/src/screens/recent_orders/home/CONTEXT.md b/genlatte/genlatte-ui/lib/src/screens/recent_orders/home/CONTEXT.md new file mode 100644 index 0000000..b782c21 --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/screens/recent_orders/home/CONTEXT.md @@ -0,0 +1,26 @@ +# Recent Orders Home + +**Purpose:** +Manages the "Recent Orders" visualization screen, sometimes known as the "Boba Screen" or "Floating Bubble Mode." This is a highly complex, 60fps physics simulation that runs a deterministic 2D elastic collision model to float recently moderated latte images around a display, culminating in a Hero "Thanos Snap" animation when swapping content in and out. + +**Detailed File Overviews:** + +- `recent_orders_home.dart`: + - **Description**: Barrel export file. + +- `recent_orders_home_bloc.dart`: + - **Description**: Simplified state management. + - **Core Logic**: Listens to the `RecentLatteImage` repository and simply pipes a sliced/sorted array of the N newest images into the `RecentOrdersHomeState`. Also tracks the currently `activeImage` selected by a user. + +- `recent_orders_home_view.dart`: + - **Description**: The massively complex physics engine driving the particle visualization system. + - **Core Logic**: + - **Physics Simulation (`_updateLattePositions`)**: Runs an internal `Timer` fired at 16ms (60 fps) calculating distance vectors, horizontal/vertical wall bounces, and inter-bubble elastic collisions (utilizing absolute to relative resolution computations along normal tangents). + - **DOM Management (`_LatteImagesInnerState`)**: Renders bubbles utilizing a "3-Layer rendering strategy". + - Layer 1 renders the stable floating bubbles tracked by the engine matrix. + - Layer 2 tracks "Ghost" bubbles when an old bubble is bumped out of the pool via an incoming array shift. It computes a localized velocity momentum trajectory while applying a dust particle "Thanos Snap" exit. + - Layer 3 renders the detail-Hero view, locking mechanics when a consumer clicks on a bubble to inspect it. + +**Dependencies/Relationships:** +- Deep reliance on mathematical representations tracked in `recent_orders/models` (like `LattePosition` and `Collision`). +- Depends heavily on the animations hosted in `recent_orders/widgets` (like `SquishableLatteImage` and `ThanosSnappable`). diff --git a/genlatte/genlatte-ui/lib/src/screens/recent_orders/home/recent_orders_home.dart b/genlatte/genlatte-ui/lib/src/screens/recent_orders/home/recent_orders_home.dart new file mode 100644 index 0000000..9f68fe6 --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/screens/recent_orders/home/recent_orders_home.dart @@ -0,0 +1,6 @@ +// Copyright 2014 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 'recent_orders_home_bloc.dart'; +export 'recent_orders_home_view.dart'; diff --git a/genlatte/genlatte-ui/lib/src/screens/recent_orders/home/recent_orders_home_bloc.dart b/genlatte/genlatte-ui/lib/src/screens/recent_orders/home/recent_orders_home_bloc.dart new file mode 100644 index 0000000..c19d19c --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/screens/recent_orders/home/recent_orders_home_bloc.dart @@ -0,0 +1,105 @@ +// Copyright 2014 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:data_layer/data_layer.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:genlatte_data/models.dart'; +import 'package:get_it/get_it.dart'; + +part 'recent_orders_home_bloc.freezed.dart'; + +typedef _Emit = Emitter; + +/// {@template RecentOrdersHomeBloc} +/// {@endtemplate} +class RecentOrdersHomeBloc + extends Bloc { + /// {@macro RecentOrdersHomeBloc} + RecentOrdersHomeBloc({required this.numberOfImages}) + : super(RecentOrdersHomeState.initial()) { + on( + (event, _Emit emit) => switch (event) { + UpdatedRecentImages() => _onUpdatedRecentImages(event, emit), + SetActiveImage() => _onSetActiveImage(event, emit), + }, + ); + + _recentImagesRepository = GetIt.I>(); + + // TODO(craiglabenz): add server timestamps to ensure pulling the N newest + // images + // TODO(craiglabenz): add a limit to only pull N (probably 13 based on + // Figma) most recent images + final stream = _recentImagesRepository.watchList(); + + _recentImagesSubscription = stream.listen((recentImages) { + add(RecentOrdersHomeEvent.updatedRecentImages(recentImages)); + }); + } + + /// The number of recent images to display. + final int numberOfImages; + + late final Repository _recentImagesRepository; + late final StreamSubscription> + _recentImagesSubscription; + + Future _onUpdatedRecentImages( + UpdatedRecentImages event, + _Emit emit, + ) async { + List sortedImages = List.from( + event.images, + )..sort((a, b) => b.createdAt.compareTo(a.createdAt)); + + sortedImages = sortedImages.length > numberOfImages + ? sortedImages.sublist(0, numberOfImages) + : sortedImages; + + emit(state.copyWith(images: sortedImages)); + } + + void _onSetActiveImage(SetActiveImage event, _Emit emit) => + emit(state.copyWith(activeImage: event.activeImage)); + + @override + Future close() { + _recentImagesSubscription.cancel().ignore(); + return super.close(); + } +} + +/// Actions that can be taken on the RecentOrdersHome page. +@Freezed() +sealed class RecentOrdersHomeEvent with _$RecentOrdersHomeEvent { + /// New recent images have arrived from the server. + const factory RecentOrdersHomeEvent.updatedRecentImages( + List images, + ) = UpdatedRecentImages; + + /// The user has toggled the active image. If the user has de-selected the + /// active image, this value will be null. + const factory RecentOrdersHomeEvent.setActiveImage( + RecentLatteImage? activeImage, + ) = SetActiveImage; +} + +/// {@template RecentOrdersHomeState} +/// Complete representation of the RecentOrdersHome page's state. +/// {@endtemplate +@Freezed() +sealed class RecentOrdersHomeState with _$RecentOrdersHomeState { + /// {@macro RecentOrdersHomeState} + const factory RecentOrdersHomeState({ + @Default([]) List images, + RecentLatteImage? activeImage, + }) = _RecentOrdersHomeState; + const RecentOrdersHomeState._(); + + /// Starter state fed to the [RecentOrdersHomeBloc]. + factory RecentOrdersHomeState.initial() => const RecentOrdersHomeState(); +} diff --git a/genlatte/genlatte-ui/lib/src/screens/recent_orders/home/recent_orders_home_bloc.freezed.dart b/genlatte/genlatte-ui/lib/src/screens/recent_orders/home/recent_orders_home_bloc.freezed.dart new file mode 100644 index 0000000..69217e4 --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/screens/recent_orders/home/recent_orders_home_bloc.freezed.dart @@ -0,0 +1,612 @@ +// Copyright 2014 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. + +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'recent_orders_home_bloc.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; +/// @nodoc +mixin _$RecentOrdersHomeEvent { + + + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is RecentOrdersHomeEvent); +} + + +@override +int get hashCode => runtimeType.hashCode; + +@override +String toString() { + return 'RecentOrdersHomeEvent()'; +} + + +} + +/// @nodoc +class $RecentOrdersHomeEventCopyWith<$Res> { +$RecentOrdersHomeEventCopyWith(RecentOrdersHomeEvent _, $Res Function(RecentOrdersHomeEvent) __); +} + + +/// Adds pattern-matching-related methods to [RecentOrdersHomeEvent]. +extension RecentOrdersHomeEventPatterns on RecentOrdersHomeEvent { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap({TResult Function( UpdatedRecentImages value)? updatedRecentImages,TResult Function( SetActiveImage value)? setActiveImage,required TResult orElse(),}){ +final _that = this; +switch (_that) { +case UpdatedRecentImages() when updatedRecentImages != null: +return updatedRecentImages(_that);case SetActiveImage() when setActiveImage != null: +return setActiveImage(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map({required TResult Function( UpdatedRecentImages value) updatedRecentImages,required TResult Function( SetActiveImage value) setActiveImage,}){ +final _that = this; +switch (_that) { +case UpdatedRecentImages(): +return updatedRecentImages(_that);case SetActiveImage(): +return setActiveImage(_that);} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull({TResult? Function( UpdatedRecentImages value)? updatedRecentImages,TResult? Function( SetActiveImage value)? setActiveImage,}){ +final _that = this; +switch (_that) { +case UpdatedRecentImages() when updatedRecentImages != null: +return updatedRecentImages(_that);case SetActiveImage() when setActiveImage != null: +return setActiveImage(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen({TResult Function( List images)? updatedRecentImages,TResult Function( RecentLatteImage? activeImage)? setActiveImage,required TResult orElse(),}) {final _that = this; +switch (_that) { +case UpdatedRecentImages() when updatedRecentImages != null: +return updatedRecentImages(_that.images);case SetActiveImage() when setActiveImage != null: +return setActiveImage(_that.activeImage);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when({required TResult Function( List images) updatedRecentImages,required TResult Function( RecentLatteImage? activeImage) setActiveImage,}) {final _that = this; +switch (_that) { +case UpdatedRecentImages(): +return updatedRecentImages(_that.images);case SetActiveImage(): +return setActiveImage(_that.activeImage);} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull({TResult? Function( List images)? updatedRecentImages,TResult? Function( RecentLatteImage? activeImage)? setActiveImage,}) {final _that = this; +switch (_that) { +case UpdatedRecentImages() when updatedRecentImages != null: +return updatedRecentImages(_that.images);case SetActiveImage() when setActiveImage != null: +return setActiveImage(_that.activeImage);case _: + return null; + +} +} + +} + +/// @nodoc + + +class UpdatedRecentImages implements RecentOrdersHomeEvent { + const UpdatedRecentImages(final List images): _images = images; + + + final List _images; + List get images { + if (_images is EqualUnmodifiableListView) return _images; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_images); +} + + +/// Create a copy of RecentOrdersHomeEvent +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$UpdatedRecentImagesCopyWith get copyWith => _$UpdatedRecentImagesCopyWithImpl(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is UpdatedRecentImages&&const DeepCollectionEquality().equals(other._images, _images)); +} + + +@override +int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(_images)); + +@override +String toString() { + return 'RecentOrdersHomeEvent.updatedRecentImages(images: $images)'; +} + + +} + +/// @nodoc +abstract mixin class $UpdatedRecentImagesCopyWith<$Res> implements $RecentOrdersHomeEventCopyWith<$Res> { + factory $UpdatedRecentImagesCopyWith(UpdatedRecentImages value, $Res Function(UpdatedRecentImages) _then) = _$UpdatedRecentImagesCopyWithImpl; +@useResult +$Res call({ + List images +}); + + + + +} +/// @nodoc +class _$UpdatedRecentImagesCopyWithImpl<$Res> + implements $UpdatedRecentImagesCopyWith<$Res> { + _$UpdatedRecentImagesCopyWithImpl(this._self, this._then); + + final UpdatedRecentImages _self; + final $Res Function(UpdatedRecentImages) _then; + +/// Create a copy of RecentOrdersHomeEvent +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') $Res call({Object? images = null,}) { + return _then(UpdatedRecentImages( +null == images ? _self._images : images // ignore: cast_nullable_to_non_nullable +as List, + )); +} + + +} + +/// @nodoc + + +class SetActiveImage implements RecentOrdersHomeEvent { + const SetActiveImage(this.activeImage); + + + final RecentLatteImage? activeImage; + +/// Create a copy of RecentOrdersHomeEvent +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$SetActiveImageCopyWith get copyWith => _$SetActiveImageCopyWithImpl(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is SetActiveImage&&(identical(other.activeImage, activeImage) || other.activeImage == activeImage)); +} + + +@override +int get hashCode => Object.hash(runtimeType,activeImage); + +@override +String toString() { + return 'RecentOrdersHomeEvent.setActiveImage(activeImage: $activeImage)'; +} + + +} + +/// @nodoc +abstract mixin class $SetActiveImageCopyWith<$Res> implements $RecentOrdersHomeEventCopyWith<$Res> { + factory $SetActiveImageCopyWith(SetActiveImage value, $Res Function(SetActiveImage) _then) = _$SetActiveImageCopyWithImpl; +@useResult +$Res call({ + RecentLatteImage? activeImage +}); + + +$RecentLatteImageCopyWith<$Res>? get activeImage; + +} +/// @nodoc +class _$SetActiveImageCopyWithImpl<$Res> + implements $SetActiveImageCopyWith<$Res> { + _$SetActiveImageCopyWithImpl(this._self, this._then); + + final SetActiveImage _self; + final $Res Function(SetActiveImage) _then; + +/// Create a copy of RecentOrdersHomeEvent +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') $Res call({Object? activeImage = freezed,}) { + return _then(SetActiveImage( +freezed == activeImage ? _self.activeImage : activeImage // ignore: cast_nullable_to_non_nullable +as RecentLatteImage?, + )); +} + +/// Create a copy of RecentOrdersHomeEvent +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$RecentLatteImageCopyWith<$Res>? get activeImage { + if (_self.activeImage == null) { + return null; + } + + return $RecentLatteImageCopyWith<$Res>(_self.activeImage!, (value) { + return _then(_self.copyWith(activeImage: value)); + }); +} +} + +/// @nodoc +mixin _$RecentOrdersHomeState { + + List get images; RecentLatteImage? get activeImage; +/// Create a copy of RecentOrdersHomeState +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$RecentOrdersHomeStateCopyWith get copyWith => _$RecentOrdersHomeStateCopyWithImpl(this as RecentOrdersHomeState, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is RecentOrdersHomeState&&const DeepCollectionEquality().equals(other.images, images)&&(identical(other.activeImage, activeImage) || other.activeImage == activeImage)); +} + + +@override +int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(images),activeImage); + +@override +String toString() { + return 'RecentOrdersHomeState(images: $images, activeImage: $activeImage)'; +} + + +} + +/// @nodoc +abstract mixin class $RecentOrdersHomeStateCopyWith<$Res> { + factory $RecentOrdersHomeStateCopyWith(RecentOrdersHomeState value, $Res Function(RecentOrdersHomeState) _then) = _$RecentOrdersHomeStateCopyWithImpl; +@useResult +$Res call({ + List images, RecentLatteImage? activeImage +}); + + +$RecentLatteImageCopyWith<$Res>? get activeImage; + +} +/// @nodoc +class _$RecentOrdersHomeStateCopyWithImpl<$Res> + implements $RecentOrdersHomeStateCopyWith<$Res> { + _$RecentOrdersHomeStateCopyWithImpl(this._self, this._then); + + final RecentOrdersHomeState _self; + final $Res Function(RecentOrdersHomeState) _then; + +/// Create a copy of RecentOrdersHomeState +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? images = null,Object? activeImage = freezed,}) { + return _then(_self.copyWith( +images: null == images ? _self.images : images // ignore: cast_nullable_to_non_nullable +as List,activeImage: freezed == activeImage ? _self.activeImage : activeImage // ignore: cast_nullable_to_non_nullable +as RecentLatteImage?, + )); +} +/// Create a copy of RecentOrdersHomeState +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$RecentLatteImageCopyWith<$Res>? get activeImage { + if (_self.activeImage == null) { + return null; + } + + return $RecentLatteImageCopyWith<$Res>(_self.activeImage!, (value) { + return _then(_self.copyWith(activeImage: value)); + }); +} +} + + +/// Adds pattern-matching-related methods to [RecentOrdersHomeState]. +extension RecentOrdersHomeStatePatterns on RecentOrdersHomeState { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _RecentOrdersHomeState value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _RecentOrdersHomeState() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _RecentOrdersHomeState value) $default,){ +final _that = this; +switch (_that) { +case _RecentOrdersHomeState(): +return $default(_that);} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _RecentOrdersHomeState value)? $default,){ +final _that = this; +switch (_that) { +case _RecentOrdersHomeState() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( List images, RecentLatteImage? activeImage)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _RecentOrdersHomeState() when $default != null: +return $default(_that.images,_that.activeImage);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( List images, RecentLatteImage? activeImage) $default,) {final _that = this; +switch (_that) { +case _RecentOrdersHomeState(): +return $default(_that.images,_that.activeImage);} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( List images, RecentLatteImage? activeImage)? $default,) {final _that = this; +switch (_that) { +case _RecentOrdersHomeState() when $default != null: +return $default(_that.images,_that.activeImage);case _: + return null; + +} +} + +} + +/// @nodoc + + +class _RecentOrdersHomeState extends RecentOrdersHomeState { + const _RecentOrdersHomeState({final List images = const [], this.activeImage}): _images = images,super._(); + + + final List _images; +@override@JsonKey() List get images { + if (_images is EqualUnmodifiableListView) return _images; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_images); +} + +@override final RecentLatteImage? activeImage; + +/// Create a copy of RecentOrdersHomeState +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$RecentOrdersHomeStateCopyWith<_RecentOrdersHomeState> get copyWith => __$RecentOrdersHomeStateCopyWithImpl<_RecentOrdersHomeState>(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _RecentOrdersHomeState&&const DeepCollectionEquality().equals(other._images, _images)&&(identical(other.activeImage, activeImage) || other.activeImage == activeImage)); +} + + +@override +int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(_images),activeImage); + +@override +String toString() { + return 'RecentOrdersHomeState(images: $images, activeImage: $activeImage)'; +} + + +} + +/// @nodoc +abstract mixin class _$RecentOrdersHomeStateCopyWith<$Res> implements $RecentOrdersHomeStateCopyWith<$Res> { + factory _$RecentOrdersHomeStateCopyWith(_RecentOrdersHomeState value, $Res Function(_RecentOrdersHomeState) _then) = __$RecentOrdersHomeStateCopyWithImpl; +@override @useResult +$Res call({ + List images, RecentLatteImage? activeImage +}); + + +@override $RecentLatteImageCopyWith<$Res>? get activeImage; + +} +/// @nodoc +class __$RecentOrdersHomeStateCopyWithImpl<$Res> + implements _$RecentOrdersHomeStateCopyWith<$Res> { + __$RecentOrdersHomeStateCopyWithImpl(this._self, this._then); + + final _RecentOrdersHomeState _self; + final $Res Function(_RecentOrdersHomeState) _then; + +/// Create a copy of RecentOrdersHomeState +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? images = null,Object? activeImage = freezed,}) { + return _then(_RecentOrdersHomeState( +images: null == images ? _self._images : images // ignore: cast_nullable_to_non_nullable +as List,activeImage: freezed == activeImage ? _self.activeImage : activeImage // ignore: cast_nullable_to_non_nullable +as RecentLatteImage?, + )); +} + +/// Create a copy of RecentOrdersHomeState +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$RecentLatteImageCopyWith<$Res>? get activeImage { + if (_self.activeImage == null) { + return null; + } + + return $RecentLatteImageCopyWith<$Res>(_self.activeImage!, (value) { + return _then(_self.copyWith(activeImage: value)); + }); +} +} + +// dart format on diff --git a/genlatte/genlatte-ui/lib/src/screens/recent_orders/home/recent_orders_home_view.dart b/genlatte/genlatte-ui/lib/src/screens/recent_orders/home/recent_orders_home_view.dart new file mode 100644 index 0000000..20f737a --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/screens/recent_orders/home/recent_orders_home_view.dart @@ -0,0 +1,1109 @@ +// Copyright 2014 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:math'; +import 'dart:ui' show lerpDouble; + +import 'package:firebase_auth/firebase_auth.dart'; +import 'package:flutter/foundation.dart' show kIsWeb; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:genlatte/src/screens/app/app.dart'; +import 'package:genlatte/src/screens/recent_orders/home/recent_orders_home.dart'; +import 'package:genlatte/src/screens/recent_orders/models/models.dart'; +import 'package:genlatte/src/screens/recent_orders/widgets/widgets.dart'; +import 'package:genlatte/src/widgets/widgets.dart'; +import 'package:genlatte_data/models.dart'; +import 'package:get_it/get_it.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart' hide OverflowMarquee; + +/// {@template RecentOrdersHomeScreen} +/// Initial RecentOrdersHome screen. +/// {@endtemplate} +class RecentOrdersHomeScreen extends StatefulWidget { + /// {@macro RecentOrdersHomeScreen} + const RecentOrdersHomeScreen({super.key}); + + @override + State createState() => _RecentOrdersHomeScreenState(); +} + +class _RecentOrdersHomeScreenState extends State { + final RecentOrdersHomeBloc bloc = RecentOrdersHomeBloc( + numberOfImages: _startingLattePositions.length, + ); + + // TODO(craiglabenz): DELETE THIS. + // This is only for testing the arrival of new images. + // final _recentImagesRepo = RecentLatteImagesRepository(); + + static const _basePortraitSize = Size(720, 1200); + static const _baseLandscapeSize = Size(1200, 720); + + @override + Widget build(BuildContext context) { + return LayoutProvider.builder( + builder: (context, layoutInfo) { + final scale = layoutInfo.orientation.isLandscape + ? layoutInfo.width / _baseLandscapeSize.width + : layoutInfo.height / _basePortraitSize.height; + + final genLatteBannerTopPadding = 30 * scale; + final genLatteBannerHeight = 50 * scale; + final totalGenLatteHeight = + genLatteBannerTopPadding + genLatteBannerHeight; + return BlocBuilder( + bloc: bloc, + builder: (context, state) { + return SafeArea( + top: !kIsWeb, + left: false, + bottom: false, + right: false, + child: Scaffold( + backgroundColor: AppColors.white, + child: Stack( + children: [ + Positioned.fill( + child: _LatteImagesInner( + activeImage: state.activeImage, + clearActiveImage: () => + bloc.add(const SetActiveImage(null)), + images: state.images, + layoutInfo: layoutInfo, + setActiveImage: (image) => + bloc.add(SetActiveImage(image)), + ), + ), + Positioned( + top: genLatteBannerTopPadding, + width: layoutInfo.width, + height: genLatteBannerHeight, + child: Center( + child: TripleTapDetector( + semanticLabel: 'Header', + semanticHint: 'Triple tap to sign out', + onPressed: () => GetIt.I().signOut(), + child: Text( + 'GenLatte', + style: TextStyle(fontSize: 48 * scale), + ), + ), + ), + ), + Positioned( + top: totalGenLatteHeight + genLatteBannerTopPadding, + height: genLatteBannerHeight, + width: layoutInfo.width, + child: Center( + child: Footer( + fontColor: AppColors.black, + uiScale: layoutInfo.width / 560, + ), + ), + ), + // Positioned( + // right: 20, + // bottom: 20, + // height: 45, + // width: 150, + // child: GenLatteOutlinedButton.dark( + // label: 'Add new image', + // onPressed: _recentImagesRepo.addRandomRecentImage, + // ), + // ), + if (state.activeImage != null) + Positioned.fill( + child: GestureDetector( + onTap: () => bloc.add(const SetActiveImage(null)), + ), + ), + ], + ), + ), + ); + }, + ); + }, + ); + } + + @override + Future dispose() async { + await bloc.close(); + super.dispose(); + } +} + +/// The inner widget that manages the floating latte images, their physics, +/// and the transition between list and detail views. +class _LatteImagesInner extends StatefulWidget { + const _LatteImagesInner({ + required this.activeImage, + required this.clearActiveImage, + required this.images, + required this.layoutInfo, + required this.setActiveImage, + }); + + final RecentLatteImage? activeImage; + final VoidCallback clearActiveImage; + final List images; + final LayoutInformation layoutInfo; + final void Function(RecentLatteImage) setActiveImage; + + @override + State<_LatteImagesInner> createState() => _LatteImagesInnerState(); +} + +class _LatteImagesInnerState extends State<_LatteImagesInner> + with TickerProviderStateMixin { + /// The current physics state of all latte "slots" on the screen. + List _lattePositions = []; + + /// The timer that drives the 60fps physics simulation. + Timer? _timer; + + /// The images that are currently playing their Thanos snap animation. + final List<_SnappingImage> _snappingImages = []; + + /// The URLs of images that have arrived but are waiting for a Thanos snap + /// to complete before they fade in. + final Set _pendingImageUrls = {}; + + /// A value of 0 means the UI is in the list view, with floating bubbles. + /// Any value between 0 and 1 means the UI is transitioning, and a value of 1 + /// means the UI is in the detail view. + double _selectedLatteImageAnimationProgress = 0; + + /// The image that is currently being showcased in the detail view, or is + /// in the process of animating in or out. + RecentLatteImage? _heroImage; + + /// Controls the transition animation between the grid and detail views. + late AnimationController _selectedLatteImageController; + + /// The number of indices each image has shifted by through the + /// [_startingLattePositions] list. This allows images to rotate through + /// stable positions while looking like they are constantly arriving. + int _imageIndexShift = 0; + + static const double _halfPi = pi / 2; + + @override + void initState() { + super.initState(); + _lattePositions = List.from(_startingLattePositions); + _selectedLatteImageController = + AnimationController( + vsync: this, + duration: const Duration(milliseconds: 600), + )..addListener(() { + setState(() { + _selectedLatteImageAnimationProgress = + _selectedLatteImageController.value; + + if (_selectedLatteImageAnimationProgress == 0 && + widget.activeImage == null) { + _heroImage = null; + } + }); + }); + + _startTimer(); + } + + void _startTimer() { + _stopTimer(); + + /// 60 fps + _timer = Timer.periodic(const Duration(milliseconds: 16), (timer) { + _updateLattePositions(); + }); + } + + void _stopTimer() => _timer?.cancel(); + + @override + void didUpdateWidget(covariant _LatteImagesInner oldWidget) { + super.didUpdateWidget(oldWidget); + + final didListGrow = widget.images.length != oldWidget.images.length; + final didListShift = + widget.images.firstOrNull?.imageUrl != + oldWidget.images.firstOrNull?.imageUrl; + + if (didListGrow || didListShift) { + // Logic for handling "new image arrival": + // 1. Identify which "slot" in the physics engine will receive the new + // image. + // 2. Take a snapshot of the image currently in that slot. + // 3. Move that image into the "snapping images" list to play its exit + // animation. + // 4. Update the _imageIndexShift so the new image correctly maps to that + // slot. + + final oldShift = _imageIndexShift; + final newShift = (oldShift + 1) % _startingLattePositions.length; + + // The physical slot mapped to index 0 (newest image) after the shift. + final pIndex = + (_startingLattePositions.length - newShift) % + _startingLattePositions.length; + + final newImageUrl = widget.images[0].imageUrl; + final isAddition = widget.images.length > oldWidget.images.length; + + // Find the image index that previously occupied this physical slot. + final oldIndex = (pIndex + oldShift) % _startingLattePositions.length; + + if (!isAddition && + oldIndex < oldWidget.images.length && + oldWidget.images[oldIndex].imageUrl != newImageUrl) { + final removedImage = oldWidget.images[oldIndex]; + final layoutSize = Size( + widget.layoutInfo.width, + widget.layoutInfo.height, + ); + + // Capture the position and velocity of the image exactly at the moment + // it is swapped out to ensure the ghost continues its momentum. + final absPos = _lattePositions[pIndex].toAbsolute(layoutSize); + + final velocity = Offset( + cos(absPos.direction) * absPos.speed, + sin(absPos.direction) * absPos.speed, + ); + + // Track this image as "pending" so it stays invisible until the snap + // finishes. + _pendingImageUrls.add(newImageUrl); + + _snappingImages.add( + _SnappingImage( + image: removedImage, + position: absPos, + associatedNewImageUrl: newImageUrl, + velocity: velocity, + startTime: DateTime.now(), + ), + ); + } + + _calculateImageIndexShift(); + } + + // Handle transitions between the interactive bubble grid and the focused + // detail view. + if (widget.activeImage != oldWidget.activeImage) { + if (widget.activeImage == null) { + _animateToListView(); + } else { + // Prepare image for hero transition. + _heroImage = widget.activeImage; + + // Pause physics for better performance/focus in detail view. + _stopTimer(); + _animateToDetailView(); + } + } + } + + /// The list has changed, which requires us to sort out where the newest + /// image is in the list. + void _calculateImageIndexShift() { + _imageIndexShift++; + if (_imageIndexShift >= _startingLattePositions.length) { + _imageIndexShift = 0; + } + } + + /// The main physics loop: handles movement, repulsion from other lattes, + /// and wall bouncing. + void _updateLattePositions() { + if (widget.layoutInfo.width <= 0 || widget.layoutInfo.height <= 0) return; + + // No need to animate anything while we're in detail mode. + if (_selectedLatteImageAnimationProgress == 1) return; + + final layoutSize = Size(widget.layoutInfo.width, widget.layoutInfo.height); + final updatedPositions = _lattePositions.indexed.map((indexAndPos) { + final i = indexAndPos.$1; + final pos = indexAndPos.$2; + + final index = (i + _imageIndexShift) % _startingLattePositions.length; + if (index >= widget.images.length) { + return pos.toAbsolute(layoutSize); + } + + double targetRadius = pos.radius; + final imageUrl = widget.images[index].imageUrl; + if (_pendingImageUrls.contains(imageUrl)) { + // If the image at this position is still pending its appearance, + // we force it to target its current radius, thus avoiding + // any expansion until it is ready to be shown. + targetRadius = pos.radius; + } else { + targetRadius = _startingLattePositions[index].radius; + } + + final newRadius = pos.radius + (targetRadius - pos.radius) * 0.1; + + return pos.copyWith(radius: newRadius).toAbsolute(layoutSize); + }).toList(); + + // Step 2: Wall Bouncing and Movement + _lattePositions = updatedPositions.indexed.map((indexAndAbs) { + final i = indexAndAbs.$1; + final absPosition = indexAndAbs.$2; + + final index = (i + _imageIndexShift) % _startingLattePositions.length; + if (index >= widget.images.length) { + return absPosition.toRelative(layoutSize); + } + + double direction = absPosition.direction; + double? squishAngle = absPosition.squishAngle; + double speed = absPosition.speed; + + bool horizontalBounce = false; + + // -------- HORIZONTAL BOUNCE (LEFT / RIGHT WALLS) -------- + if ((absPosition.center.dx - absPosition.radius <= 0 && + cos(direction) < 0) || + (absPosition.center.dx + absPosition.radius >= layoutSize.width && + cos(direction) > 0)) { + direction = pi - direction; + horizontalBounce = true; + } + + bool verticalBounce = false; + + // -------- VERTICAL BOUNCE (TOP / BOTTOM WALLS) -------- + if ((absPosition.center.dy - absPosition.radius <= 0 && + sin(direction) < 0) || + (absPosition.center.dy + absPosition.radius >= layoutSize.height && + sin(direction) > 0)) { + direction = -direction; + verticalBounce = true; + } + + // If we bounced against either wall, configure the new squish angle + if (horizontalBounce || verticalBounce) { + squishAngle = horizontalBounce ? pi / 2 : 0; + + // Return speed to absolute representation of default speed. + final vx = + LattePosition.defaultSpeed * cos(direction) * layoutSize.width; + final vy = + LattePosition.defaultSpeed * sin(direction) * layoutSize.height; + speed = sqrt(vx * vx + vy * vy); + } + + final mappedPos = absPosition.copyWith( + center: absPosition.center.copyWith( + dx: absPosition.center.dx + cos(direction) * speed, + dy: absPosition.center.dy + sin(direction) * speed, + ), + direction: direction, + squishAngle: squishAngle, + speed: speed, + lastCollidedWith: horizontalBounce || verticalBounce + ? null + : absPosition.lastCollidedWith, + lastCollisionAt: (horizontalBounce || verticalBounce) + ? DateTime.now() + : null, + ); + + return mappedPos.toRelative(layoutSize); + }).toList(); + + // Step 3: Inter-bubble collisions + // We convert all positions to absolute pixel coordinates once for this pass + final absPositions = _lattePositions + .map((p) => p.toAbsolute(layoutSize)) + .toList(); + + for (int i = 0; i < absPositions.length; i++) { + final indexI = (i + _imageIndexShift) % 13; + if (indexI >= widget.images.length) continue; + + for (int j = i + 1; j < absPositions.length; j++) { + final indexJ = (j + _imageIndexShift) % 13; + if (indexJ >= widget.images.length) continue; + + final a = absPositions[i]; + final b = absPositions[j]; + + // Ensure these two objects didn't just bounce off each other + if (a.lastCollidedWith == b.id && b.lastCollidedWith == a.id) { + continue; + } + + final dx = b.center.dx - a.center.dx; + final dy = b.center.dy - a.center.dy; + + // Use length squared to avoid computing square root prematurely + final lengthSquared = (dx * dx) + (dy * dy); + final minDistance = a.radius + b.radius; + final minDistanceSquared = minDistance * minDistance; + + if (lengthSquared < minDistanceSquared) { + // Calculate the pixel overlap + final distance = sqrt(lengthSquared); + final safeDistance = distance == 0 ? 0.0001 : distance; + final overlap = minDistance - safeDistance; + + final nx = dx / safeDistance; + final ny = dy / safeDistance; + + // Convert pixel separation back to percentages + final moveX = nx * overlap / 2.0; + final moveY = ny * overlap / 2.0; + + // Create translated copies for positional correction + final correctedA = a.copyWith( + center: a.center.translate(-moveX, -moveY), + ); + final correctedB = b.copyWith( + center: b.center.translate(moveX, moveY), + ); + + // Phase 2: Elastic resolution + final vAx = a.speed * cos(a.direction); + final vAy = a.speed * sin(a.direction); + final vBx = b.speed * cos(b.direction); + final vBy = b.speed * sin(b.direction); + + final dotProduct = ((vAx - vBx) * dx) + ((vAy - vBy) * dy); + + if (dotProduct > 0) { + final collision = Collision(correctedA, correctedB); + final result = collision.resolve(); + + // Store the resolved state back into the absolute list + absPositions[i] = correctedA.copyWith( + direction: result.latte1Direction, + squishAngle: result.latte1Squish.direction + _halfPi, + speed: result.latte1Speed, + lastCollisionAt: DateTime.now(), + lastCollidedWith: b.id, + ); + absPositions[j] = correctedB.copyWith( + direction: result.latte2Direction, + squishAngle: result.latte2Squish.direction + _halfPi, + speed: result.latte2Speed, + lastCollisionAt: DateTime.now(), + lastCollidedWith: a.id, + ); + } else { + // Only update positions if we didn't resolve a new velocity + absPositions[i] = correctedA; + absPositions[j] = correctedB; + } + } + } + } + + // Convert back to relative coordinates for the next frame + _lattePositions = absPositions + .map((abs) => abs.toRelative(layoutSize)) + .toList(); + + setState(() {}); + } + + void _toggleActiveImage(RecentLatteImage? image) { + if (widget.activeImage == null) { + assert(image != null, 'Cannot set an active image when none is selected'); + widget.setActiveImage(image!); + } else { + widget.clearActiveImage(); + } + } + + @override + Widget build(BuildContext context) { + if (widget.images.isEmpty) { + return const SizedBox.shrink(); + } + final heroImageUrl = _heroImage?.imageUrl; + AbsoluteLattePosition? heroGridPos; + + final heightScale = widget.layoutInfo.height / 800; + + final showOnListViewOpacity = _selectedLatteImageAnimationProgress; + + // The layout for recent orders uses a 3-layer rendering strategy to handle + // live physics, list changes, and hero transitions simultaneously. + return Stack( + clipBehavior: Clip.none, + children: [ + // Layer 0: Background circles. Decorative elements that fade based on + // whether the detail view (activeImage) is visible. + ..._filledCircleData.map( + (circle) => circle.toWidget( + widget.layoutInfo, + isDetail: widget.activeImage != null, + ), + ), + + // Layer 1: Stable floating bubbles. + // These represent the images in the actual grid as managed by the + // physics engine. We map physical slots (0-12) to these bubbles. + ..._lattePositions.indexed.expand( + (indexAndPosition) sync* { + final position = indexAndPosition.$2; + final slotIndex = indexAndPosition.$1; + + final index = + (slotIndex + _imageIndexShift) % _startingLattePositions.length; + + if (index >= widget.images.length) { + yield const SizedBox.shrink(); + return; + } + + final image = widget.images[index]; + final isHero = image.imageUrl == heroImageUrl; + + final absPos = position.toAbsolute( + Size(widget.layoutInfo.width, widget.layoutInfo.height), + ); + + if (isHero) { + heroGridPos = absPos; + } + + // To keep coordinate systems consistent between the grid and the + // hero transition, we use a "3R" rule: each image container is + // exactly 3x the radius of the image, centered on the image's + // coordinate. + final boxSize = absPos.radius * 3; + + final isPending = _pendingImageUrls.contains(image.imageUrl); + + final hideOnHeroOpacity = isHero + ? 0.0 + : (1.0 - _selectedLatteImageAnimationProgress); + yield Positioned( + left: absPos.center.dx - boxSize / 2, + top: absPos.center.dy - boxSize / 2, + height: boxSize, + width: boxSize, + key: ValueKey('position-${position.id}'), + child: Opacity( + // Fade out non-hero images during detail transition. + opacity: hideOnHeroOpacity, + child: AnimatedOpacity( + // Fade in new images when they arrive. + opacity: isPending ? 0.0 : 1.0, + duration: isPending + ? Duration.zero + : const Duration(milliseconds: 500), + child: IgnorePointer( + ignoring: widget.activeImage != null, + child: SquishableLatteImage( + image: image, + lattePosition: isHero + ? absPos.copyWith(squishAngle: null) + : absPos, + ), + ), + ), + ), + ); + + if (widget.activeImage == null) { + // Now add a hit box for the latte image, which is different from + // its own rendering bounding box, which has to have extra padding + // to accommodate the wobble animation. However, that extra + // padding can cause close-proximity images to overlap one another + yield Positioned( + left: absPos.center.dx - boxSize / 2 + absPos.radius / 2, + top: absPos.center.dy - boxSize / 2 + absPos.radius / 2, + + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: () => _toggleActiveImage(image), + child: SizedBox.fromSize( + size: Size.square(absPos.radius * 2), + ), + ), + ); + } + }, + ), + + // Layer 2: "Ghosts" of lattes that have just been replaced. + // When a new image arrives, it displaces an old one. This layer + // captures the displaced image and applies a "Thanos snap" effect while + // preserving its momentum from the physics loop. + ..._snappingImages.map((snapping) { + final padding = snapping.position.radius * 2; + final elapsedTime = DateTime.now().difference(snapping.startTime); + final displacement = + snapping.velocity * (elapsedTime.inMicroseconds / 16666.0); + final center = snapping.position.center + displacement; + + return Positioned( + key: ValueKey('snap-${snapping.associatedNewImageUrl}'), + left: center.dx - snapping.position.radius - padding, + top: center.dy - snapping.position.radius - padding, + height: snapping.position.radius * 2 + padding * 2, + width: snapping.position.radius * 2 + padding * 2, + child: Center( + child: ThanosSnappable( + onComplete: () { + if (mounted) { + setState(() { + _snappingImages.remove(snapping); + _pendingImageUrls.remove( + snapping.associatedNewImageUrl, + ); + }); + } + }, + child: SquishableLatteImage( + image: snapping.image, + lattePosition: snapping.position.copyWith( + center: center, + ), + ), + ), + ), + ); + }), + + // Layer 3: The hero image animating to the front and center. + // This is a single image that "breaks out" of Layer 1 to become the + // central focus of the detail view. It tracks its original grid + // position (captured in Layer 1 as 'heroGridPos') to ensure seamless + // takeoff and landing. + if (_heroImage != null && heroGridPos != null) ...[ + Positioned( + left: widget.layoutInfo.width * 0.1, + width: widget.layoutInfo.width * 0.8, + top: + widget.layoutInfo.height * 0.17 + + // Add some more top padding for square aspect ratios because + // that keeps the footer from crowding the image explainer text + // too much. + (widget.layoutInfo.aspectRatio > 1.2 && + widget.layoutInfo.aspectRatio < 1.4 + ? widget.layoutInfo.height * 0.03 + : 0), + bottom: 0, + child: Opacity( + opacity: showOnListViewOpacity, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: widget.layoutInfo.aspectRatio < 1.4 + ? MainAxisAlignment.start + : MainAxisAlignment.center, + children: [ + Text('${_heroImage!.name}:').h1, + const Text('My happy place is').h2_, + SizedBox(height: 16 * heightScale), + Text('“${_heroImage!.happyPlace}”').h3, + SizedBox(height: 8 * heightScale), + SizedBox( + width: + widget.layoutInfo.width * + (widget.layoutInfo.aspectRatio < 1.4 ? 0.6 : 0.4), + child: Row( + children: [ + const Text( + 'Prompt: ', + style: TextStyle(fontWeight: .bold), + ), + SizedBox(width: 8 * heightScale), + Flexible( + flex: 3, + child: OverflowMarquee(text: _heroImage!.prompt), + ), + ], + ), + ), + ], + ), + ), + ), + _HeroLatte( + image: _heroImage!, + gridPosition: heroGridPos!, + layoutInfo: widget.layoutInfo, + progress: _selectedLatteImageAnimationProgress, + onTap: widget.clearActiveImage, + ), + ], + ], + ); + } + + void _animateToListView() { + _selectedLatteImageController + .animateTo(0, curve: Curves.easeInOutExpo) + .then((_) { + if (mounted && widget.activeImage == null) { + _startTimer(); + } + }) + .ignore(); + } + + void _animateToDetailView() { + _selectedLatteImageController + .animateTo(1, curve: Curves.easeInOutExpo) + .ignore(); + } + + @override + Future dispose() async { + _timer?.cancel(); + super.dispose(); + } +} + +class _FilledCircleData { + const _FilledCircleData({ + required this.color, + required this.radius, + required this.position, + this.showOnDetailView = true, + }); + + final Color color; + final double radius; + final Offset position; + + /// Set to false if this appears behind or even too close to lettering + final bool showOnDetailView; + + /// Arbitrary constant used to scale the background circles to the screen + /// size. + static const double _baseSize = 500; + + Widget toWidget(LayoutInformation layoutInfo, {required bool isDetail}) { + final scale = layoutInfo.constrainingDimension / _baseSize; + return _FilledCircle( + color: color, + radius: radius * scale, + position: Offset( + position.dx * layoutInfo.width, + position.dy * layoutInfo.height, + ), + opacity: showOnDetailView || !isDetail ? 1.0 : 0.0, + ); + } +} + +class _FilledCircle extends StatelessWidget { + const _FilledCircle({ + required this.color, + required this.radius, + required this.position, + required this.opacity, + }); + + final Color color; + final double radius; + final Offset position; + final double opacity; + + @override + Widget build(BuildContext context) { + return Positioned( + left: position.dx, + top: position.dy, + width: radius * 2, + height: radius * 2, + key: ValueKey('$position-$radius-$color'), + child: AnimatedOpacity( + opacity: opacity, + duration: const Duration(milliseconds: 600), + child: CustomPaint( + painter: _FilledCirclePainter(color: color), + ), + ), + ); + } +} + +class _FilledCirclePainter extends CustomPainter { + _FilledCirclePainter({required this.color}); + + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final paint = Paint() + ..color = color + ..style = PaintingStyle.fill; + canvas.drawCircle( + Offset(size.width / 2, size.height / 2), + size.width / 2, + paint, + ); + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => false; +} + +const _filledCircleData = [ + _FilledCircleData( + color: AppColors.googleIntroRed, + radius: 10, + position: Offset(0.1, 0.1), + ), + _FilledCircleData( + color: AppColors.googleIntroGreen, + radius: 5, + position: Offset(0.9, 0.1), + ), + _FilledCircleData( + color: AppColors.googleIntroBlue, + radius: 8, + position: Offset(0.4, 0.7), + ), + _FilledCircleData( + color: AppColors.googleIntroBlue, + radius: 12, + position: Offset(0.7, 0.35), + ), + _FilledCircleData( + color: AppColors.googleIntroRed, + radius: 15, + position: Offset(0.2, 0.65), + showOnDetailView: false, + ), + _FilledCircleData( + color: AppColors.latteArtGold, + radius: 12, + position: Offset(0.8, 0.75), + ), + _FilledCircleData( + color: AppColors.latteArtGold, + radius: 6, + position: Offset(0.2, 0.21), + showOnDetailView: false, + ), + _FilledCircleData( + color: AppColors.googleIntroRed, + radius: 9, + position: Offset(0.75, 0.22), + ), + _FilledCircleData( + color: AppColors.latteArtGold, + radius: 11, + position: Offset(0.5, 0.5), + ), + _FilledCircleData( + color: AppColors.googleIntroBlue, + radius: 7, + position: Offset(0.3, 0.45), + showOnDetailView: false, + ), + _FilledCircleData( + color: AppColors.googleIntroRed, + radius: 13, + position: Offset(0.7, 0.8), + ), + _FilledCircleData( + color: AppColors.latteArtGold, + radius: 13, + position: Offset(0.15, 0.8), + ), + _FilledCircleData( + color: AppColors.googleIntroGreen, + radius: 8, + position: Offset(0.2, 0.3), + showOnDetailView: false, + ), + _FilledCircleData( + color: AppColors.googleIntroGreen, + radius: 8, + position: Offset(0.45, 0.85), + ), +]; + +/// The predefined "home" positions for floating latte images. +/// These use relative percentages (0.0 to 1.0) to ensure the design remains +/// responsive regardless of screen size. +const _startingLattePositions = [ + LattePosition( + id: '1', + center: Offset(0.5, 0.5), + radius: 0.120, + direction: 0.5, + squishAngle: null, + ), + LattePosition( + id: '2', + center: Offset(0.2, 0.3), + radius: 0.110, + direction: 1.2, + squishAngle: null, + ), + LattePosition( + id: '3', + center: Offset(0.8, 0.1), + radius: 0.102, + direction: 2.1, + squishAngle: null, + ), + LattePosition( + id: '4', + center: Offset(0.7, 0.7), + radius: 0.093, + direction: 3.5, + squishAngle: null, + ), + LattePosition( + id: '5', + center: Offset(0.3, 0.8), + radius: 0.086, + direction: 4.8, + squishAngle: null, + ), + LattePosition( + id: '6', + center: Offset(0.1, 0.6), + radius: 0.079, + direction: 5.9, + squishAngle: null, + ), + LattePosition( + id: '7', + center: Offset(0.9, 0.5), + radius: 0.073, + direction: 0.2, + squishAngle: null, + ), + LattePosition( + id: '8', + center: Offset(0.4, 0.2), + radius: 0.067, + direction: 1.7, + squishAngle: null, + ), + LattePosition( + id: '9', + center: Offset(0.6, 0.9), + radius: 0.062, + direction: 2.9, + squishAngle: null, + ), + LattePosition( + id: '10', + center: Offset(0.8, 0.8), + radius: 0.057, + direction: 4.1, + squishAngle: null, + ), + LattePosition( + id: '11', + center: Offset(0.2, 0.9), + radius: 0.052, + direction: 5.3, + squishAngle: null, + ), + LattePosition( + id: '12', + center: Offset(0.9, 0.9), + radius: 0.048, + direction: 6.1, + squishAngle: null, + ), + LattePosition( + id: '13', + center: Offset(0.4, 0.5), + radius: 0.044, + direction: 3.14, + squishAngle: null, + ), +]; + +/// Internal record used to manage the state of an image currently undergoing +/// the "Thanos snap" disintegration animation. +class _SnappingImage { + _SnappingImage({ + required this.image, + required this.position, + required this.associatedNewImageUrl, + required this.velocity, + required this.startTime, + }); + + final RecentLatteImage image; + final AbsoluteLattePosition position; + final String associatedNewImageUrl; + final Offset velocity; + final DateTime startTime; +} + +class _HeroLatte extends StatelessWidget { + const _HeroLatte({ + required this.image, + required this.gridPosition, + required this.layoutInfo, + required this.progress, + required this.onTap, + }); + + final RecentLatteImage image; + final AbsoluteLattePosition gridPosition; + final LayoutInformation layoutInfo; + final double progress; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + // The target center for the detail view. + // We adjust the focus point slightly based on aspect ratio to avoid + // crowding the footer text in landscape or tablet views. + final center = layoutInfo.aspectRatio < 1.4 + ? Offset(layoutInfo.width / 2, layoutInfo.height * 0.67) + : Offset(layoutInfo.width * 0.75, layoutInfo.height * 0.6); + + // The target size for the detail view. + // We want the image to be substantial but not overwhelming. + final targetRadius = layoutInfo.aspectRatio < 1.4 + ? layoutInfo.height * 0.14 + : layoutInfo.width * 0.10; + + // Coordinate Interpolation (The "Hero" part): + // We calculate a current position and radius that is a mix of the image's + // 'home' position in the grid and its 'focus' position in the detail view. + final currentCenter = Offset.lerp(gridPosition.center, center, progress)!; + final currentRadius = lerpDouble( + gridPosition.radius, + targetRadius, + progress, + )!; + + // Transitioning Layout Math: + // To prevent the image from "snapping" or "jumping" when it takes off + // from the grid, it's vital that the Positioned box math matches exactly + // across all layers. We use the '3R' rule established in Layer 1. + final boxSize = currentRadius * 3; + + return Positioned( + top: currentCenter.dy - boxSize / 2, + left: currentCenter.dx - boxSize / 2, + width: boxSize, + height: boxSize, + child: SquishableLatteImage( + image: image, + lattePosition: gridPosition.copyWith( + center: currentCenter, + radius: currentRadius, + ), + paddingPercentage: 1 - progress, + ), + ); + } +} diff --git a/genlatte/genlatte-ui/lib/src/screens/recent_orders/models/CONTEXT.md b/genlatte/genlatte-ui/lib/src/screens/recent_orders/models/CONTEXT.md new file mode 100644 index 0000000..a3c8f0b --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/screens/recent_orders/models/CONTEXT.md @@ -0,0 +1,22 @@ +# Recent Orders Models + +**Purpose:** +Hosts the mathematical data constructs necessary to run the 60fps physics simulation for the "Boba Mode" Recent Orders bubble visualization. These exist outside the presentation layer to separate pure physics calculations from DOM rendering. + +**Detailed File Overviews:** + +- `models.dart`: + - **Description**: Barrel export file. + +- `latte_position.dart`: + - **Description**: Core physics matrices and mathematical formulas. + - **Core Logic**: + - Exposes dual mathematical representations of bounding boxes (`LattePosition` using relative coordinate percentages `[0.0-1.0]` vs `AbsoluteLattePosition` using static `` constraints) which resolves display responsiveness issues during resizing events. + - Encapsulates the core `Collision.resolve()` math that uses elastic vector dot-products over tangent impact lines to compute vector momentum conservation after two spheres collide. + +- `wobble_calculator.dart`: + - **Description**: A mathematical function generator. + - **Core Logic**: Drives the logic behind `SquishableLatteImage` that reduces sine-wave amplitude over time following a collision impact to simulate elastic "jelly" properties. + +**Dependencies/Relationships:** +- Serves as the math/state backing for components in both `recent_orders/home` and `recent_orders/widgets`. diff --git a/genlatte/genlatte-ui/lib/src/screens/recent_orders/models/latte_position.dart b/genlatte/genlatte-ui/lib/src/screens/recent_orders/models/latte_position.dart new file mode 100644 index 0000000..cd2ed2f --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/screens/recent_orders/models/latte_position.dart @@ -0,0 +1,324 @@ +// Copyright 2014 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:math'; + +import 'package:flutter/animation.dart' show Curves; +import 'package:flutter/widgets.dart' show Offset, Size; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'latte_position.freezed.dart'; + +/// A latte position represented entirely in absolute pixel coordinates. +@freezed +abstract class AbsoluteLattePosition with _$AbsoluteLattePosition { + /// Creates a new [AbsoluteLattePosition]. + const factory AbsoluteLattePosition({ + /// Unique identifier to track collision pairs. + required String id, + + /// The center of the latte image. + required Offset center, + + /// The angle of this Latte's movement in radians. + required double direction, + + /// The radius of the latte image as a percentage of the most constraine + /// dimension of the available space. A value of 1.0 would indicate a latte + /// that fills the entire available space. + required double radius, + + /// The angle of the squish effect. + required double? squishAngle, + + /// The speed of this latte's movement. + required double speed, + + /// The last time this latte was involved in a collision. + DateTime? lastCollisionAt, + + /// The ID of the last object this collided with. + String? lastCollidedWith, + }) = _AbsoluteLattePosition; + + const AbsoluteLattePosition._(); + + /// The mass of this latte, used for physics calculations. + double get mass => radius * radius; + + /// Flattens an [AbsoluteLattePosition] into a relative values format, using + /// the current screen dimensions for this frame. + LattePosition toRelative(Size layoutSize) { + final relCx = center.dx / layoutSize.width; + final relCy = center.dy / layoutSize.height; + + final vx = speed * cos(direction); + final vy = speed * sin(direction); + + final relVx = vx / layoutSize.width; + final relVy = vy / layoutSize.height; + final relSpeed = sqrt(relVx * relVx + relVy * relVy); + final relDir = atan2(relVy, relVx); + + return LattePosition( + id: id, + center: Offset(relCx, relCy), + radius: radius / layoutSize.shortestSide, + direction: relDir, + rawSpeed: relSpeed, + squishAngle: squishAngle, + lastCollisionAt: lastCollisionAt, + lastCollidedWith: lastCollidedWith, + ); + } +} + +/// Represents the position of a latte image on the screen. +@freezed +abstract class LattePosition with _$LattePosition { + /// Creates a new [LattePosition]. + const factory LattePosition({ + /// Unique identifier to track collision pairs. + required String id, + + /// The center of the latte image. + required Offset center, + + /// The angle of this Latte's movement in radians. + required double direction, + + /// Size of the latte image as a percentage of the most constrained + /// dimension of the available space. A value of 1.0 would indicate a latte. + required double radius, + + /// The angle of the squish effect. + required double? squishAngle, + + /// The raw speed as calculated off of the last collision. If this is + /// greater than [defaultSpeed], then friction will slow the latte down + /// until it returns to [defaultSpeed] + @Default(LattePosition.defaultSpeed) double rawSpeed, + + /// The last time this latte was involved in a collision. This is used to + /// calculate friction to slow down a circle's velocity. + DateTime? lastCollisionAt, + + /// The ID of the last object this collided with to prevent rapid duplicate + /// collisions. + String? lastCollidedWith, + }) = _LattePosition; + + const LattePosition._(); + + // : rawSpeed = speed, + // mass = ; + + /// The speed at which each ball wants to move. + static const defaultSpeed = 0.0002; + + /// Mass of the latte image, used for physics calculations. + double get mass => radius * radius; + + static const Duration _decelerationDuration = Duration(seconds: 3); + + /// The speed of this Latte's movement. + double get speed { + if (rawSpeed <= defaultSpeed) { + return rawSpeed; + } + + final timeSinceLastCollision = lastCollisionAt == null + ? Duration.zero + : DateTime.now().difference(lastCollisionAt!); + + if (lastCollisionAt == null || + timeSinceLastCollision >= _decelerationDuration) { + return rawSpeed; + } + return defaultSpeed + + ((rawSpeed - defaultSpeed) * + Curves.easeOut.transform( + timeSinceLastCollision.inMicroseconds / + _decelerationDuration.inMicroseconds, + )); + } + + /// Converts a [LattePosition] into a format of absolute pixel values, using + /// the current screen dimensions for this frame. + AbsoluteLattePosition toAbsolute(Size layoutSize) { + final absRadius = radius * layoutSize.shortestSide; + + final vx = speed * cos(direction) * layoutSize.width; + final vy = speed * sin(direction) * layoutSize.height; + + final absSpeed = sqrt(vx * vx + vy * vy); + final absDir = atan2(vy, vx); + + return AbsoluteLattePosition( + id: id, + center: Offset( + center.dx * layoutSize.width, + center.dy * layoutSize.height, + ), + direction: absDir, + radius: absRadius, + squishAngle: squishAngle, + speed: absSpeed, + lastCollisionAt: lastCollisionAt, + lastCollidedWith: lastCollidedWith, + ); + } +} + +/// Two circles that have collided. +class Collision { + /// Creates a new [Collision]. + Collision(this.c1, this.c2); + + /// The first circle. + final AbsoluteLattePosition c1; + + /// The second circle. + final AbsoluteLattePosition c2; + + /// Resolve this collision. + CollisionResult resolve() { + // 1. Calculate the normal vector (line connecting centers in pixel space) + final dx = c2.center.dx - c1.center.dx; + final dy = c2.center.dy - c1.center.dy; + + // Calculate the angles of the normal line + final c1SquishAngle = atan2(dy, dx); + final c2SquishAngle = atan2(-dy, -dx); // Exact opposite direction + + final distance = sqrt(dx * dx + dy * dy); + + // Prevent division by zero if centers perfectly overlap + if (distance == 0) { + return CollisionResult( + c1.speed, + c1.direction, + c2.speed, + c2.direction, + SquishData(c1SquishAngle, 0), + SquishData(c2SquishAngle, 0), + ); + } + + // Unit normal vector + final nx = dx / distance; + final ny = dy / distance; + + // Unit tangent vector (perpendicular to normal) + final tx = -ny; + final ty = nx; + + // 2. Convert speed and direction to purely Pixel velocity vectors + final c1Vx = c1.speed * cos(c1.direction); + final c1Vy = c1.speed * sin(c1.direction); + final c2Vx = c2.speed * cos(c2.direction); + final c2Vy = c2.speed * sin(c2.direction); + + // 3. Project velocities onto normal and tangent vectors (Dot Product) + final c1Vn = c1Vx * nx + c1Vy * ny; + final c1Vt = c1Vx * tx + c1Vy * ty; + final c2Vn = c2Vx * nx + c2Vy * ny; + final c2Vt = c2Vx * tx + c2Vy * ty; + + final relativeNormalVelocity = c1Vn - c2Vn; + final impactForce = relativeNormalVelocity.abs(); + + final c1Squish = SquishData(c1SquishAngle, impactForce); + final c2Squish = SquishData(c2SquishAngle, impactForce); + + // 4. Resolve the 1D collision along the normal vector + final m1 = c1.mass; + final m2 = c2.mass; + + final c1VnAfter = (c1Vn * (m1 - m2) + 2 * m2 * c2Vn) / (m1 + m2); + final c2VnAfter = (c2Vn * (m2 - m1) + 2 * m1 * c1Vn) / (m1 + m2); + + // Tangential velocities do not change (c1_vt_after = c1_vt) + + // 5. Convert the scalar normal and tangential velocities back into Pixel + // vectors. + // Vector = (Normal_Scalar * Normal_Vector) + (Tangent_Scalar * + // Tangent_Vector) + final c1VxAfter = (c1VnAfter * nx) + (c1Vt * tx); + final c1VyAfter = (c1VnAfter * ny) + (c1Vt * ty); + + final c2VxAfter = (c2VnAfter * nx) + (c2Vt * tx); + final c2VyAfter = (c2VnAfter * ny) + (c2Vt * ty); + + // 6. Convert Pixel X/Y back to Speed and Direction (Polar) + final c1SpeedAfter = sqrt(c1VxAfter * c1VxAfter + c1VyAfter * c1VyAfter); + final c1DirAfter = atan2(c1VyAfter, c1VxAfter); + + final c2SpeedAfter = sqrt(c2VxAfter * c2VxAfter + c2VyAfter * c2VyAfter); + final c2DirAfter = atan2(c2VyAfter, c2VxAfter); + + return CollisionResult( + c1SpeedAfter, + c1DirAfter, + c2SpeedAfter, + c2DirAfter, + c1Squish, + c2Squish, + ); + } +} + +/// The result of a collision. +class CollisionResult { + /// Creates a new [CollisionResult]. + CollisionResult( + this.latte1Speed, + this.latte1Direction, + this.latte2Speed, + this.latte2Direction, + this.latte1Squish, + this.latte2Squish, + ); + + /// The speed of the first circle. + final double latte1Speed; + + /// The direction of the first circle in radians. + final double latte1Direction; + + /// The speed of the second circle. + final double latte2Speed; + + /// The direction of the second circle in radians. + final double latte2Direction; + + /// The squish data for the first circle. + final SquishData latte1Squish; + + /// The squish data for the second circle. + final SquishData latte2Squish; +} + +/// Holds the data for a squish effect. +class SquishData { + /// Creates a new [SquishData]. + SquishData(this.direction, this.intensity); + + /// The direction of the squish effect in radians. + final double direction; + + /// The intensity of the squish effect, representing the force of impact. + final double intensity; +} + +/// Extension to add copyWith to Offset. +extension CopyableOffset on Offset { + /// Creates a copy of this [Offset]. + Offset copyWith({ + double? dx, + double? dy, + }) { + return Offset(dx ?? this.dx, dy ?? this.dy); + } +} diff --git a/genlatte/genlatte-ui/lib/src/screens/recent_orders/models/latte_position.freezed.dart b/genlatte/genlatte-ui/lib/src/screens/recent_orders/models/latte_position.freezed.dart new file mode 100644 index 0000000..b6980ee --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/screens/recent_orders/models/latte_position.freezed.dart @@ -0,0 +1,620 @@ +// Copyright 2014 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. + +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'latte_position.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; +/// @nodoc +mixin _$AbsoluteLattePosition { + +/// Unique identifier to track collision pairs. + String get id;/// The center of the latte image. + Offset get center;/// The angle of this Latte's movement in radians. + double get direction;/// The radius of the latte image as a percentage of the most constraine +/// dimension of the available space. A value of 1.0 would indicate a latte +/// that fills the entire available space. + double get radius;/// The angle of the squish effect. + double? get squishAngle;/// The speed of this latte's movement. + double get speed;/// The last time this latte was involved in a collision. + DateTime? get lastCollisionAt;/// The ID of the last object this collided with. + String? get lastCollidedWith; +/// Create a copy of AbsoluteLattePosition +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$AbsoluteLattePositionCopyWith get copyWith => _$AbsoluteLattePositionCopyWithImpl(this as AbsoluteLattePosition, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is AbsoluteLattePosition&&(identical(other.id, id) || other.id == id)&&(identical(other.center, center) || other.center == center)&&(identical(other.direction, direction) || other.direction == direction)&&(identical(other.radius, radius) || other.radius == radius)&&(identical(other.squishAngle, squishAngle) || other.squishAngle == squishAngle)&&(identical(other.speed, speed) || other.speed == speed)&&(identical(other.lastCollisionAt, lastCollisionAt) || other.lastCollisionAt == lastCollisionAt)&&(identical(other.lastCollidedWith, lastCollidedWith) || other.lastCollidedWith == lastCollidedWith)); +} + + +@override +int get hashCode => Object.hash(runtimeType,id,center,direction,radius,squishAngle,speed,lastCollisionAt,lastCollidedWith); + +@override +String toString() { + return 'AbsoluteLattePosition(id: $id, center: $center, direction: $direction, radius: $radius, squishAngle: $squishAngle, speed: $speed, lastCollisionAt: $lastCollisionAt, lastCollidedWith: $lastCollidedWith)'; +} + + +} + +/// @nodoc +abstract mixin class $AbsoluteLattePositionCopyWith<$Res> { + factory $AbsoluteLattePositionCopyWith(AbsoluteLattePosition value, $Res Function(AbsoluteLattePosition) _then) = _$AbsoluteLattePositionCopyWithImpl; +@useResult +$Res call({ + String id, Offset center, double direction, double radius, double? squishAngle, double speed, DateTime? lastCollisionAt, String? lastCollidedWith +}); + + + + +} +/// @nodoc +class _$AbsoluteLattePositionCopyWithImpl<$Res> + implements $AbsoluteLattePositionCopyWith<$Res> { + _$AbsoluteLattePositionCopyWithImpl(this._self, this._then); + + final AbsoluteLattePosition _self; + final $Res Function(AbsoluteLattePosition) _then; + +/// Create a copy of AbsoluteLattePosition +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? center = null,Object? direction = null,Object? radius = null,Object? squishAngle = freezed,Object? speed = null,Object? lastCollisionAt = freezed,Object? lastCollidedWith = freezed,}) { + return _then(_self.copyWith( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String,center: null == center ? _self.center : center // ignore: cast_nullable_to_non_nullable +as Offset,direction: null == direction ? _self.direction : direction // ignore: cast_nullable_to_non_nullable +as double,radius: null == radius ? _self.radius : radius // ignore: cast_nullable_to_non_nullable +as double,squishAngle: freezed == squishAngle ? _self.squishAngle : squishAngle // ignore: cast_nullable_to_non_nullable +as double?,speed: null == speed ? _self.speed : speed // ignore: cast_nullable_to_non_nullable +as double,lastCollisionAt: freezed == lastCollisionAt ? _self.lastCollisionAt : lastCollisionAt // ignore: cast_nullable_to_non_nullable +as DateTime?,lastCollidedWith: freezed == lastCollidedWith ? _self.lastCollidedWith : lastCollidedWith // ignore: cast_nullable_to_non_nullable +as String?, + )); +} + +} + + +/// Adds pattern-matching-related methods to [AbsoluteLattePosition]. +extension AbsoluteLattePositionPatterns on AbsoluteLattePosition { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _AbsoluteLattePosition value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _AbsoluteLattePosition() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _AbsoluteLattePosition value) $default,){ +final _that = this; +switch (_that) { +case _AbsoluteLattePosition(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _AbsoluteLattePosition value)? $default,){ +final _that = this; +switch (_that) { +case _AbsoluteLattePosition() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String id, Offset center, double direction, double radius, double? squishAngle, double speed, DateTime? lastCollisionAt, String? lastCollidedWith)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _AbsoluteLattePosition() when $default != null: +return $default(_that.id,_that.center,_that.direction,_that.radius,_that.squishAngle,_that.speed,_that.lastCollisionAt,_that.lastCollidedWith);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String id, Offset center, double direction, double radius, double? squishAngle, double speed, DateTime? lastCollisionAt, String? lastCollidedWith) $default,) {final _that = this; +switch (_that) { +case _AbsoluteLattePosition(): +return $default(_that.id,_that.center,_that.direction,_that.radius,_that.squishAngle,_that.speed,_that.lastCollisionAt,_that.lastCollidedWith);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String id, Offset center, double direction, double radius, double? squishAngle, double speed, DateTime? lastCollisionAt, String? lastCollidedWith)? $default,) {final _that = this; +switch (_that) { +case _AbsoluteLattePosition() when $default != null: +return $default(_that.id,_that.center,_that.direction,_that.radius,_that.squishAngle,_that.speed,_that.lastCollisionAt,_that.lastCollidedWith);case _: + return null; + +} +} + +} + +/// @nodoc + + +class _AbsoluteLattePosition extends AbsoluteLattePosition { + const _AbsoluteLattePosition({required this.id, required this.center, required this.direction, required this.radius, required this.squishAngle, required this.speed, this.lastCollisionAt, this.lastCollidedWith}): super._(); + + +/// Unique identifier to track collision pairs. +@override final String id; +/// The center of the latte image. +@override final Offset center; +/// The angle of this Latte's movement in radians. +@override final double direction; +/// The radius of the latte image as a percentage of the most constraine +/// dimension of the available space. A value of 1.0 would indicate a latte +/// that fills the entire available space. +@override final double radius; +/// The angle of the squish effect. +@override final double? squishAngle; +/// The speed of this latte's movement. +@override final double speed; +/// The last time this latte was involved in a collision. +@override final DateTime? lastCollisionAt; +/// The ID of the last object this collided with. +@override final String? lastCollidedWith; + +/// Create a copy of AbsoluteLattePosition +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$AbsoluteLattePositionCopyWith<_AbsoluteLattePosition> get copyWith => __$AbsoluteLattePositionCopyWithImpl<_AbsoluteLattePosition>(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _AbsoluteLattePosition&&(identical(other.id, id) || other.id == id)&&(identical(other.center, center) || other.center == center)&&(identical(other.direction, direction) || other.direction == direction)&&(identical(other.radius, radius) || other.radius == radius)&&(identical(other.squishAngle, squishAngle) || other.squishAngle == squishAngle)&&(identical(other.speed, speed) || other.speed == speed)&&(identical(other.lastCollisionAt, lastCollisionAt) || other.lastCollisionAt == lastCollisionAt)&&(identical(other.lastCollidedWith, lastCollidedWith) || other.lastCollidedWith == lastCollidedWith)); +} + + +@override +int get hashCode => Object.hash(runtimeType,id,center,direction,radius,squishAngle,speed,lastCollisionAt,lastCollidedWith); + +@override +String toString() { + return 'AbsoluteLattePosition(id: $id, center: $center, direction: $direction, radius: $radius, squishAngle: $squishAngle, speed: $speed, lastCollisionAt: $lastCollisionAt, lastCollidedWith: $lastCollidedWith)'; +} + + +} + +/// @nodoc +abstract mixin class _$AbsoluteLattePositionCopyWith<$Res> implements $AbsoluteLattePositionCopyWith<$Res> { + factory _$AbsoluteLattePositionCopyWith(_AbsoluteLattePosition value, $Res Function(_AbsoluteLattePosition) _then) = __$AbsoluteLattePositionCopyWithImpl; +@override @useResult +$Res call({ + String id, Offset center, double direction, double radius, double? squishAngle, double speed, DateTime? lastCollisionAt, String? lastCollidedWith +}); + + + + +} +/// @nodoc +class __$AbsoluteLattePositionCopyWithImpl<$Res> + implements _$AbsoluteLattePositionCopyWith<$Res> { + __$AbsoluteLattePositionCopyWithImpl(this._self, this._then); + + final _AbsoluteLattePosition _self; + final $Res Function(_AbsoluteLattePosition) _then; + +/// Create a copy of AbsoluteLattePosition +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? center = null,Object? direction = null,Object? radius = null,Object? squishAngle = freezed,Object? speed = null,Object? lastCollisionAt = freezed,Object? lastCollidedWith = freezed,}) { + return _then(_AbsoluteLattePosition( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String,center: null == center ? _self.center : center // ignore: cast_nullable_to_non_nullable +as Offset,direction: null == direction ? _self.direction : direction // ignore: cast_nullable_to_non_nullable +as double,radius: null == radius ? _self.radius : radius // ignore: cast_nullable_to_non_nullable +as double,squishAngle: freezed == squishAngle ? _self.squishAngle : squishAngle // ignore: cast_nullable_to_non_nullable +as double?,speed: null == speed ? _self.speed : speed // ignore: cast_nullable_to_non_nullable +as double,lastCollisionAt: freezed == lastCollisionAt ? _self.lastCollisionAt : lastCollisionAt // ignore: cast_nullable_to_non_nullable +as DateTime?,lastCollidedWith: freezed == lastCollidedWith ? _self.lastCollidedWith : lastCollidedWith // ignore: cast_nullable_to_non_nullable +as String?, + )); +} + + +} + +/// @nodoc +mixin _$LattePosition { + +/// Unique identifier to track collision pairs. + String get id;/// The center of the latte image. + Offset get center;/// The angle of this Latte's movement in radians. + double get direction;/// Size of the latte image as a percentage of the most constrained +/// dimension of the available space. A value of 1.0 would indicate a latte. + double get radius;/// The angle of the squish effect. + double? get squishAngle;/// The raw speed as calculated off of the last collision. If this is +/// greater than [defaultSpeed], then friction will slow the latte down +/// until it returns to [defaultSpeed] + double get rawSpeed;/// The last time this latte was involved in a collision. This is used to +/// calculate friction to slow down a circle's velocity. + DateTime? get lastCollisionAt;/// The ID of the last object this collided with to prevent rapid duplicate +/// collisions. + String? get lastCollidedWith; +/// Create a copy of LattePosition +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$LattePositionCopyWith get copyWith => _$LattePositionCopyWithImpl(this as LattePosition, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is LattePosition&&(identical(other.id, id) || other.id == id)&&(identical(other.center, center) || other.center == center)&&(identical(other.direction, direction) || other.direction == direction)&&(identical(other.radius, radius) || other.radius == radius)&&(identical(other.squishAngle, squishAngle) || other.squishAngle == squishAngle)&&(identical(other.rawSpeed, rawSpeed) || other.rawSpeed == rawSpeed)&&(identical(other.lastCollisionAt, lastCollisionAt) || other.lastCollisionAt == lastCollisionAt)&&(identical(other.lastCollidedWith, lastCollidedWith) || other.lastCollidedWith == lastCollidedWith)); +} + + +@override +int get hashCode => Object.hash(runtimeType,id,center,direction,radius,squishAngle,rawSpeed,lastCollisionAt,lastCollidedWith); + +@override +String toString() { + return 'LattePosition(id: $id, center: $center, direction: $direction, radius: $radius, squishAngle: $squishAngle, rawSpeed: $rawSpeed, lastCollisionAt: $lastCollisionAt, lastCollidedWith: $lastCollidedWith)'; +} + + +} + +/// @nodoc +abstract mixin class $LattePositionCopyWith<$Res> { + factory $LattePositionCopyWith(LattePosition value, $Res Function(LattePosition) _then) = _$LattePositionCopyWithImpl; +@useResult +$Res call({ + String id, Offset center, double direction, double radius, double? squishAngle, double rawSpeed, DateTime? lastCollisionAt, String? lastCollidedWith +}); + + + + +} +/// @nodoc +class _$LattePositionCopyWithImpl<$Res> + implements $LattePositionCopyWith<$Res> { + _$LattePositionCopyWithImpl(this._self, this._then); + + final LattePosition _self; + final $Res Function(LattePosition) _then; + +/// Create a copy of LattePosition +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? center = null,Object? direction = null,Object? radius = null,Object? squishAngle = freezed,Object? rawSpeed = null,Object? lastCollisionAt = freezed,Object? lastCollidedWith = freezed,}) { + return _then(_self.copyWith( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String,center: null == center ? _self.center : center // ignore: cast_nullable_to_non_nullable +as Offset,direction: null == direction ? _self.direction : direction // ignore: cast_nullable_to_non_nullable +as double,radius: null == radius ? _self.radius : radius // ignore: cast_nullable_to_non_nullable +as double,squishAngle: freezed == squishAngle ? _self.squishAngle : squishAngle // ignore: cast_nullable_to_non_nullable +as double?,rawSpeed: null == rawSpeed ? _self.rawSpeed : rawSpeed // ignore: cast_nullable_to_non_nullable +as double,lastCollisionAt: freezed == lastCollisionAt ? _self.lastCollisionAt : lastCollisionAt // ignore: cast_nullable_to_non_nullable +as DateTime?,lastCollidedWith: freezed == lastCollidedWith ? _self.lastCollidedWith : lastCollidedWith // ignore: cast_nullable_to_non_nullable +as String?, + )); +} + +} + + +/// Adds pattern-matching-related methods to [LattePosition]. +extension LattePositionPatterns on LattePosition { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _LattePosition value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _LattePosition() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _LattePosition value) $default,){ +final _that = this; +switch (_that) { +case _LattePosition(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _LattePosition value)? $default,){ +final _that = this; +switch (_that) { +case _LattePosition() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String id, Offset center, double direction, double radius, double? squishAngle, double rawSpeed, DateTime? lastCollisionAt, String? lastCollidedWith)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _LattePosition() when $default != null: +return $default(_that.id,_that.center,_that.direction,_that.radius,_that.squishAngle,_that.rawSpeed,_that.lastCollisionAt,_that.lastCollidedWith);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String id, Offset center, double direction, double radius, double? squishAngle, double rawSpeed, DateTime? lastCollisionAt, String? lastCollidedWith) $default,) {final _that = this; +switch (_that) { +case _LattePosition(): +return $default(_that.id,_that.center,_that.direction,_that.radius,_that.squishAngle,_that.rawSpeed,_that.lastCollisionAt,_that.lastCollidedWith);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String id, Offset center, double direction, double radius, double? squishAngle, double rawSpeed, DateTime? lastCollisionAt, String? lastCollidedWith)? $default,) {final _that = this; +switch (_that) { +case _LattePosition() when $default != null: +return $default(_that.id,_that.center,_that.direction,_that.radius,_that.squishAngle,_that.rawSpeed,_that.lastCollisionAt,_that.lastCollidedWith);case _: + return null; + +} +} + +} + +/// @nodoc + + +class _LattePosition extends LattePosition { + const _LattePosition({required this.id, required this.center, required this.direction, required this.radius, required this.squishAngle, this.rawSpeed = LattePosition.defaultSpeed, this.lastCollisionAt, this.lastCollidedWith}): super._(); + + +/// Unique identifier to track collision pairs. +@override final String id; +/// The center of the latte image. +@override final Offset center; +/// The angle of this Latte's movement in radians. +@override final double direction; +/// Size of the latte image as a percentage of the most constrained +/// dimension of the available space. A value of 1.0 would indicate a latte. +@override final double radius; +/// The angle of the squish effect. +@override final double? squishAngle; +/// The raw speed as calculated off of the last collision. If this is +/// greater than [defaultSpeed], then friction will slow the latte down +/// until it returns to [defaultSpeed] +@override@JsonKey() final double rawSpeed; +/// The last time this latte was involved in a collision. This is used to +/// calculate friction to slow down a circle's velocity. +@override final DateTime? lastCollisionAt; +/// The ID of the last object this collided with to prevent rapid duplicate +/// collisions. +@override final String? lastCollidedWith; + +/// Create a copy of LattePosition +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$LattePositionCopyWith<_LattePosition> get copyWith => __$LattePositionCopyWithImpl<_LattePosition>(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _LattePosition&&(identical(other.id, id) || other.id == id)&&(identical(other.center, center) || other.center == center)&&(identical(other.direction, direction) || other.direction == direction)&&(identical(other.radius, radius) || other.radius == radius)&&(identical(other.squishAngle, squishAngle) || other.squishAngle == squishAngle)&&(identical(other.rawSpeed, rawSpeed) || other.rawSpeed == rawSpeed)&&(identical(other.lastCollisionAt, lastCollisionAt) || other.lastCollisionAt == lastCollisionAt)&&(identical(other.lastCollidedWith, lastCollidedWith) || other.lastCollidedWith == lastCollidedWith)); +} + + +@override +int get hashCode => Object.hash(runtimeType,id,center,direction,radius,squishAngle,rawSpeed,lastCollisionAt,lastCollidedWith); + +@override +String toString() { + return 'LattePosition(id: $id, center: $center, direction: $direction, radius: $radius, squishAngle: $squishAngle, rawSpeed: $rawSpeed, lastCollisionAt: $lastCollisionAt, lastCollidedWith: $lastCollidedWith)'; +} + + +} + +/// @nodoc +abstract mixin class _$LattePositionCopyWith<$Res> implements $LattePositionCopyWith<$Res> { + factory _$LattePositionCopyWith(_LattePosition value, $Res Function(_LattePosition) _then) = __$LattePositionCopyWithImpl; +@override @useResult +$Res call({ + String id, Offset center, double direction, double radius, double? squishAngle, double rawSpeed, DateTime? lastCollisionAt, String? lastCollidedWith +}); + + + + +} +/// @nodoc +class __$LattePositionCopyWithImpl<$Res> + implements _$LattePositionCopyWith<$Res> { + __$LattePositionCopyWithImpl(this._self, this._then); + + final _LattePosition _self; + final $Res Function(_LattePosition) _then; + +/// Create a copy of LattePosition +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? center = null,Object? direction = null,Object? radius = null,Object? squishAngle = freezed,Object? rawSpeed = null,Object? lastCollisionAt = freezed,Object? lastCollidedWith = freezed,}) { + return _then(_LattePosition( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String,center: null == center ? _self.center : center // ignore: cast_nullable_to_non_nullable +as Offset,direction: null == direction ? _self.direction : direction // ignore: cast_nullable_to_non_nullable +as double,radius: null == radius ? _self.radius : radius // ignore: cast_nullable_to_non_nullable +as double,squishAngle: freezed == squishAngle ? _self.squishAngle : squishAngle // ignore: cast_nullable_to_non_nullable +as double?,rawSpeed: null == rawSpeed ? _self.rawSpeed : rawSpeed // ignore: cast_nullable_to_non_nullable +as double,lastCollisionAt: freezed == lastCollisionAt ? _self.lastCollisionAt : lastCollisionAt // ignore: cast_nullable_to_non_nullable +as DateTime?,lastCollidedWith: freezed == lastCollidedWith ? _self.lastCollidedWith : lastCollidedWith // ignore: cast_nullable_to_non_nullable +as String?, + )); +} + + +} + +// dart format on diff --git a/genlatte/genlatte-ui/lib/src/screens/recent_orders/models/models.dart b/genlatte/genlatte-ui/lib/src/screens/recent_orders/models/models.dart new file mode 100644 index 0000000..5055571 --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/screens/recent_orders/models/models.dart @@ -0,0 +1,6 @@ +// Copyright 2014 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 'latte_position.dart'; +export 'wobble_calculator.dart'; diff --git a/genlatte/genlatte-ui/lib/src/screens/recent_orders/models/wobble_calculator.dart b/genlatte/genlatte-ui/lib/src/screens/recent_orders/models/wobble_calculator.dart new file mode 100644 index 0000000..133feaf --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/screens/recent_orders/models/wobble_calculator.dart @@ -0,0 +1,74 @@ +// Copyright 2014 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:genlatte/src/core/utils/utils.dart'; + +/// Calculates the strength of a wobble at a given time. +class WobbleCalculator { + /// Creates a new [WobbleCalculator]. + const WobbleCalculator({ + required this.wobblesPerContact, + }); + + /// Number of phases of wobble to animate through during contact. + /// + /// A value of 1 should result in the circle squishing once and then + /// returning to normal. A value of 2 should result in the circle squishing + /// in the initial direction, swinging back into a perpendicular squish of + /// reduced amplitude, then resetting to normal. A value of 3 should result + /// in an initial squish, perpendicular squish at a reduced amplitude, then + /// original squish again at yet another reduced amplitude, before settling + /// back to normal. + /// + /// Etc etc. + final int wobblesPerContact; + + /// Calculates the strength of a wobble at a given time. + Wobble getWobble(double t) { + final phase = t.phaseWithinRange(wobblesPerContact, 1); + final progressThroughPhase = t.progressThroughPhase( + wobblesPerContact, + 1, + phase: phase, + ); + + // Each slot should imply a dampening of the strength. + final dampener = 1 - (phase / (wobblesPerContact + 1)); + + late double strength; + // Odd slots are moving in the direction of the squish and so move from + // strength 0 to 1 for the first 50%, then back from 1 to 0. + if (progressThroughPhase < 0.5) { + strength = progressThroughPhase * 2 * dampener; + } else { + strength = (1 - progressThroughPhase) * 2 * dampener; + } + + return Wobble( + strength: strength, + isPerpendicular: phase.isEven, + ); + } +} + +/// Defines the direction and strength the wobble of a widget's squish effect +/// after colliding with another object. +class Wobble { + /// Creates a new [Wobble]. + Wobble({ + required this.strength, + required this.isPerpendicular, + }); + + /// The strength of the wobble, ranging from 0 to 1. + final double strength; + + /// Whether the wobble is perpendicular to the initial squish. + final bool isPerpendicular; + + @override + String toString() => + 'Wobble(strength: $strength: ' + 'isPerpendicular: $isPerpendicular)'; +} diff --git a/genlatte/genlatte-ui/lib/src/screens/recent_orders/recent_orders.dart b/genlatte/genlatte-ui/lib/src/screens/recent_orders/recent_orders.dart new file mode 100644 index 0000000..f06cd67 --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/screens/recent_orders/recent_orders.dart @@ -0,0 +1,7 @@ +// Copyright 2014 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 'home/recent_orders_home.dart'; +export 'models/models.dart'; +export 'widgets/widgets.dart'; diff --git a/genlatte/genlatte-ui/lib/src/screens/recent_orders/widgets/CONTEXT.md b/genlatte/genlatte-ui/lib/src/screens/recent_orders/widgets/CONTEXT.md new file mode 100644 index 0000000..4ed50b9 --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/screens/recent_orders/widgets/CONTEXT.md @@ -0,0 +1,25 @@ +# Recent Orders Widgets + +**Purpose:** +Provides the highly customized, visual effect components and animated containers utilized by the physics-engine driven "Recent Orders" screen. + +**Detailed File Overviews:** + +- `widgets.dart`: + - **Description**: Barrel export file. + +- `squishable_latte_image.dart`: + - **Description**: A customized image container. + - **Core Logic**: Wraps `Image.network` into a perfectly circular `ClipOval` but utilizes a responsive `Padding` box structure that allows the inner canvas to warp when provided a collision angle and velocity via `SquishedWidget`. + +- `squished_widget.dart`: + - **Description**: The mathematical deformation transformer. + - **Core Logic**: Applies a `Matrix4.identity()` mapping and dynamically scales the X and Y axes asynchronously to flatten the image oval along an implicit tangent line. + +- `thanos_snappable.dart`: + - **Description**: The particle exit animation controller. + - **Core Logic**: An intensely complex custom painting hook used when a bubble is pushed out of the "Recent 12" queue due to an inbound array length change. Uses a custom RenderBox shader approach (or equivalent particle emitter logic) to dissolve the image into dust. + +**Dependencies/Relationships:** +- Pure aesthetic wrappers consumed strictly by the `recent_orders_home_view.dart` loop. +- Consumes constraints configured inside `recent_orders_models`. diff --git a/genlatte/genlatte-ui/lib/src/screens/recent_orders/widgets/squishable_latte_image.dart b/genlatte/genlatte-ui/lib/src/screens/recent_orders/widgets/squishable_latte_image.dart new file mode 100644 index 0000000..031dd03 --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/screens/recent_orders/widgets/squishable_latte_image.dart @@ -0,0 +1,156 @@ +// Copyright 2014 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:math'; +import 'package:genlatte/src/screens/recent_orders/models/models.dart'; +import 'package:genlatte/src/screens/recent_orders/widgets/widgets.dart'; +import 'package:genlatte/src/widgets/latte_image.dart' show LatteImageWidget; +import 'package:genlatte_data/models.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +/// A widget that displays a latte image that floats around the screen. +class SquishableLatteImage extends StatefulWidget { + /// Instantiates a [SquishableLatteImage]. + const SquishableLatteImage({ + required this.image, + required this.lattePosition, + this.paddingPercentage = 1, + this.wobblesPerContact = 6, + this.contactDuration = const Duration(milliseconds: 2000), + this.dampener = 0.3, + super.key, + }); + + /// The image to display. + final RecentLatteImage image; + + /// The position, size, direction, and speed of the latte image in pixels. + final AbsoluteLattePosition lattePosition; + + /// Number of phases of wobble to animate through during contact. + /// + /// A value of 1 should result in the circle squishing once and then + /// returning to normal. A value of 2 should result in the circle squishing + /// in the initial direction, swinging back into a perpendicular squish of + /// reduced amplitude, then resetting to normal. A value of 3 should result + /// in an initial squish, perpendicular squish at a reduced amplitude, then + /// original squish again at yet further reduced amplitude, before settling + /// back to normal. + /// + /// Etc etc. + final int wobblesPerContact; + + /// Percentage of default latte padding to be used, as represented via a + /// normalized double. + /// + /// A value of 1 is full padding to support the wobble animation, whereas a + /// value of 0 is zero padding, which will increase the rendered size of the + /// image. + final double paddingPercentage; + + /// The duration of the wobble animation. + final Duration contactDuration; + + /// Overall reduction of the wobble amplitude. Should be between 0 and 1. + final double dampener; + + @override + State createState() => _SquishableLatteImageState(); +} + +class _SquishableLatteImageState extends State + with TickerProviderStateMixin { + late final AnimationController _wobbleController; + + /// Vector of impact which is applying a squish to the image. + double? _squishAngle; + + late final WobbleCalculator _wobbleCalculator; + + @override + void initState() { + super.initState(); + + _wobbleController = + AnimationController( + vsync: this, + duration: widget.contactDuration, + ) + ..addListener(() { + if (!mounted) return; + setState(() {}); + }) + ..addStatusListener((status) { + if (!mounted) return; + if (status == AnimationStatus.completed) { + setState(() { + _squishAngle = null; + }); + } + }); + + _wobbleCalculator = WobbleCalculator( + wobblesPerContact: widget.wobblesPerContact, + ); + } + + @override + void didUpdateWidget(covariant SquishableLatteImage oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.lattePosition.lastCollisionAt != + oldWidget.lattePosition.lastCollisionAt) { + _squishAngle = widget.lattePosition.squishAngle; + if (_squishAngle != null) { + _startWobbleAnimation(); + } + } + } + + @override + void dispose() { + _wobbleController.dispose(); + super.dispose(); + } + + void _startWobbleAnimation() { + _wobbleController + ..duration = widget.contactDuration + ..forward(from: 0).ignore(); + } + + @override + Widget build(BuildContext context) { + Widget child = SizedBox( + width: widget.lattePosition.radius * 3, + height: widget.lattePosition.radius * 3, + child: Stack( + fit: StackFit.expand, + children: [ + Padding( + padding: EdgeInsets.all( + widget.lattePosition.radius * 0.5 * widget.paddingPercentage, + ), + child: LatteImageWidget( + imageUrl: widget.image.imageUrl, + ), + ), + ], + ), + ); + + if (_squishAngle != null) { + final wobble = _wobbleCalculator.getWobble(_wobbleController.value); + child = SquishedWidget( + direction: !wobble.isPerpendicular + ? _squishAngle! + : _squishAngle! + (pi / 2), + effectStrength: wobble.strength * widget.dampener, + child: child, + ); + } + + return child; + } +} diff --git a/genlatte/genlatte-ui/lib/src/screens/recent_orders/widgets/squished_widget.dart b/genlatte/genlatte-ui/lib/src/screens/recent_orders/widgets/squished_widget.dart new file mode 100644 index 0000000..a112a30 --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/screens/recent_orders/widgets/squished_widget.dart @@ -0,0 +1,56 @@ +// Copyright 2014 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_shaders/flutter_shaders.dart'; + +/// {@template SquishedWidget} +/// {@endtemplate} +class SquishedWidget extends StatelessWidget { + /// {@macro SquishedWidget} + const SquishedWidget({ + required this.effectStrength, + required this.direction, + required this.child, + super.key, + }); + + /// Widget to squish. + final Widget child; + + /// Direction of the force squishing the widget in radians. + final double direction; + + /// Strength of the force squishing the widget. The value should be between + /// -1 and 1, with a value of 0 indicating no squish. + final double effectStrength; + + @override + Widget build(BuildContext context) { + return ShaderBuilder( + (context, shader, innerChild) { + return AnimatedSampler( + (image, size, canvas) { + shader + ..setFloatUniforms((u) { + u + ..setSize(size) + ..setFloat(direction) + // The widget accepts a value of -1 to 1, but the shader + // itself is written to look best between -0.5 and 0.5, so + // we need to divide the strength by 2. + ..setFloat(effectStrength / 2); + }) + ..setImageSampler(0, image); + + canvas.drawRect(Offset.zero & size, Paint()..shader = shader); + }, + child: innerChild!, + ); + }, + assetKey: 'assets/shaders/squish.glsl', + child: child, + ); + } +} diff --git a/genlatte/genlatte-ui/lib/src/screens/recent_orders/widgets/thanos_snappable.dart b/genlatte/genlatte-ui/lib/src/screens/recent_orders/widgets/thanos_snappable.dart new file mode 100644 index 0000000..0d705e8 --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/screens/recent_orders/widgets/thanos_snappable.dart @@ -0,0 +1,127 @@ +// Copyright 2014 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. + +// ignore_for_file: implementation_imports + +import 'package:flutter/material.dart'; +import 'package:thanos_snap_effect/src/shader_builder.dart'; +import 'package:thanos_snap_effect/src/shader_painter.dart'; +import 'package:thanos_snap_effect/src/shader_x/thanos_effect_shader.dart'; +import 'package:thanos_snap_effect/src/snappable_style.dart'; +import 'package:thanos_snap_effect/src/snapshot/snapshot_builder.dart'; + +/// A widget that can be snapped using the Thanos snap effect. +class ThanosSnappable extends StatefulWidget { + /// {@macro ThanosSnappable} + const ThanosSnappable({ + required this.onComplete, + required this.child, + super.key, + }); + + /// Widget to be snapped + final Widget child; + + /// Callback to be called when the snap animation is complete + final VoidCallback onComplete; + + @override + State createState() => _ThanosSnappableState(); +} + +class _ThanosSnappableState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(seconds: 4), + ); + + _controller.addStatusListener((status) { + if (status == AnimationStatus.completed) { + widget.onComplete(); + } + }); + + // The SnapshotBuilder waits for a non-zero animation value to capture the + // widget. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + _controller.forward().ignore(); + } + }); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + // We avoid using the Snappable widget from thanos_snap_effect because it + // uses OverlayPortal to pin the effect in one fixed place. By implementing + // our own builder here and rendering the shader directly in the tree, + // the vanishing "ghost" will follow the transform and position of this + // widget as it continues its momentum across the screen. + return ShaderBuilder( + shaderAsset: ThanosSnapEffectShader.path, + xShaderBuilder: ThanosSnapEffectShader.new, + builder: (context, shader, child) { + return SnapshotBuilder( + animation: _controller, + onSnapshotReadyListener: (snapshotInfo) { + shader?.updateSnapshot(snapshotInfo); + shader?.updateStyleProperties( + ThanosSnapEffectStyleProps.fromSnappableStyle( + const SnappableStyle(), + snapshotInfo, + ), + ); + }, + builder: (context, snapshotInfo, child) { + return Stack( + clipBehavior: Clip.none, + children: [ + Visibility( + maintainState: true, + maintainSize: true, + maintainAnimation: true, + // Keep the original image visible for the first ~80ms (0.02) + // of the snap. This seamlessly masks the 1-frame WebGL/Wasm + // shader compilation hitch that causes a split-second of + // 'nothing' before the shader is fully injected into the + // render pipeline. + visible: _controller.value < 0.02 || snapshotInfo == null, + child: child, + ), + if (snapshotInfo != null && shader != null) + AnimatedBuilder( + animation: _controller, + builder: (context, child) { + shader.setAnimationValue(_controller.value); + return Positioned.fill( + child: ShaderPainter( + shader: shader.fragmentShader, + outerPadding: const EdgeInsets.all(40), + animationValue: _controller.value, + ), + ); + }, + ), + ], + ); + }, + child: child, + ); + }, + child: widget.child, + ); + } +} diff --git a/genlatte/genlatte-ui/lib/src/screens/recent_orders/widgets/widgets.dart b/genlatte/genlatte-ui/lib/src/screens/recent_orders/widgets/widgets.dart new file mode 100644 index 0000000..d036682 --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/screens/recent_orders/widgets/widgets.dart @@ -0,0 +1,7 @@ +// Copyright 2014 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 'squishable_latte_image.dart'; +export 'squished_widget.dart'; +export 'thanos_snappable.dart'; diff --git a/genlatte/genlatte-ui/lib/src/sources/CONTEXT.md b/genlatte/genlatte-ui/lib/src/sources/CONTEXT.md new file mode 100644 index 0000000..24c2058 --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/sources/CONTEXT.md @@ -0,0 +1,20 @@ +# External Sources + +**Purpose:** +Provides direct raw IO access boundaries to Cloud Infrastructure endpoints, preventing Vendor Lock-in by isolating exact Firebase APIs away from business logic repositories. + +**Detailed File Overviews:** + +- `sources.dart`: + - **Description**: Barrel export file. + +- `firestore_source.dart`: + - **Description**: The core NoSQL Database connection client. + - **Core Logic**: Interacts directly with the `cloud_firestore` plugin. Parses generic internal `Filter` tokens into Firebase-specific `Where` queries. Formats JSON maps directly into outbound streams using document snapshots. + +- `firestore_filters.dart`: + - **Description**: NoSQL Query Translator. + - **Core Logic**: Uses `freezed` to pattern-match repository filter requests and resolve them directly onto Firebase `Query` chains. + +**Dependencies/Relationships:** +- Strictly consumed by `genlatte-ui/lib/src/data` Repositories. diff --git a/genlatte/genlatte-ui/lib/src/sources/firestore_filters.dart b/genlatte/genlatte-ui/lib/src/sources/firestore_filters.dart new file mode 100644 index 0000000..037402f --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/sources/firestore_filters.dart @@ -0,0 +1,44 @@ +// Copyright 2014 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:cloud_firestore/cloud_firestore.dart' hide Filter; +import 'package:data_layer/data_layer.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'firestore_filters.freezed.dart'; +part 'firestore_filters.g.dart'; + +/// {@template FirestoreFilter} +/// Filter for Firestore queries. +/// {@endtemplate} +abstract class FirestoreFilter extends Filter { + /// {@macro FirestoreFilter} + const FirestoreFilter(); + + /// Add whatever WHERE clauses are necessary to modify this [query]. + Query apply(Query query); +} + +/// Firestore filters for queries against the Orders collection. +@Freezed() +sealed class OrdersFilter extends FirestoreFilter with _$OrdersFilter { + const OrdersFilter._(); + + /// Loads orders which are in any state other than incomplete. + const factory OrdersFilter.incompleteOrders() = IncompleteFilter; + + factory OrdersFilter.fromJson(Map json) => + _$OrdersFilterFromJson(json); + + @override + Query apply(Query query) => switch (this) { + IncompleteFilter() => query.where('status', isNotEqualTo: 'complete'), + }; + + @override + CacheKey get cacheKey => toString(); + + @override + Params toParams() => throw UnimplementedError(); +} diff --git a/genlatte/genlatte-ui/lib/src/sources/firestore_filters.freezed.dart b/genlatte/genlatte-ui/lib/src/sources/firestore_filters.freezed.dart new file mode 100644 index 0000000..d2186f5 --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/sources/firestore_filters.freezed.dart @@ -0,0 +1,217 @@ +// Copyright 2014 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. + +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'firestore_filters.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; +OrdersFilter _$OrdersFilterFromJson( + Map json +) { + return IncompleteFilter.fromJson( + json + ); +} + +/// @nodoc +mixin _$OrdersFilter { + + + + /// Serializes this OrdersFilter to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is OrdersFilter); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => runtimeType.hashCode; + +@override +String toString() { + return 'OrdersFilter()'; +} + + +} + +/// @nodoc +class $OrdersFilterCopyWith<$Res> { +$OrdersFilterCopyWith(OrdersFilter _, $Res Function(OrdersFilter) __); +} + + +/// Adds pattern-matching-related methods to [OrdersFilter]. +extension OrdersFilterPatterns on OrdersFilter { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap({TResult Function( IncompleteFilter value)? incompleteOrders,required TResult orElse(),}){ +final _that = this; +switch (_that) { +case IncompleteFilter() when incompleteOrders != null: +return incompleteOrders(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map({required TResult Function( IncompleteFilter value) incompleteOrders,}){ +final _that = this; +switch (_that) { +case IncompleteFilter(): +return incompleteOrders(_that);} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull({TResult? Function( IncompleteFilter value)? incompleteOrders,}){ +final _that = this; +switch (_that) { +case IncompleteFilter() when incompleteOrders != null: +return incompleteOrders(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen({TResult Function()? incompleteOrders,required TResult orElse(),}) {final _that = this; +switch (_that) { +case IncompleteFilter() when incompleteOrders != null: +return incompleteOrders();case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when({required TResult Function() incompleteOrders,}) {final _that = this; +switch (_that) { +case IncompleteFilter(): +return incompleteOrders();} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull({TResult? Function()? incompleteOrders,}) {final _that = this; +switch (_that) { +case IncompleteFilter() when incompleteOrders != null: +return incompleteOrders();case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class IncompleteFilter extends OrdersFilter { + const IncompleteFilter(): super._(); + factory IncompleteFilter.fromJson(Map json) => _$IncompleteFilterFromJson(json); + + + + +@override +Map toJson() { + return _$IncompleteFilterToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is IncompleteFilter); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => runtimeType.hashCode; + +@override +String toString() { + return 'OrdersFilter.incompleteOrders()'; +} + + +} + + + + +// dart format on diff --git a/genlatte/genlatte-ui/lib/src/sources/firestore_filters.g.dart b/genlatte/genlatte-ui/lib/src/sources/firestore_filters.g.dart new file mode 100644 index 0000000..2e45812 --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/sources/firestore_filters.g.dart @@ -0,0 +1,17 @@ +// Copyright 2014 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. + +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'firestore_filters.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +IncompleteFilter _$IncompleteFilterFromJson(Map json) => + IncompleteFilter(); + +Map _$IncompleteFilterToJson(IncompleteFilter instance) => + {}; diff --git a/genlatte/genlatte-ui/lib/src/sources/firestore_source.dart b/genlatte/genlatte-ui/lib/src/sources/firestore_source.dart new file mode 100644 index 0000000..dad30dc --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/sources/firestore_source.dart @@ -0,0 +1,459 @@ +// Copyright 2014 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:cloud_firestore/cloud_firestore.dart' hide Source; +import 'package:data_layer/data_layer.dart'; +import 'package:genlatte/src/sources/firestore_filters.dart'; +import 'package:logging/logging.dart'; +import 'package:stream_transform/stream_transform.dart'; + +/// {@template FirestoreSource} +/// Firestore implementation of [Source]. +/// {@endtemplate} +class FirestoreSource extends Source with WatchableSource { + /// {@macro FirestoreSource} + FirestoreSource( + this.firestore, { + required super.bindings, + this.onCreateServerTimestampFields = const [], + this.onUpdateServerTimestampFields = const [], + }) { + _log = Logger('FirestoreSource<$T>::${bindings.getListUrl().path}'); + } + + /// Firestore, baby! + final FirebaseFirestore firestore; + + /// Fields that should be set to [FieldValue.serverTimestamp()] on create. + final List onCreateServerTimestampFields; + + /// Fields that should be set to [FieldValue.serverTimestamp()] on every write + final List onUpdateServerTimestampFields; + + /// The Firestore collection for this source. + CollectionReference get collection => + _collection ??= firestore.collection(bindings.getListUrl().path); + + CollectionReference? _collection; + + late final Logger _log; + + @override + Future> getById(ReadOperation operation) => + _guarded(() => _getById(operation), operation); + + Future> _getById(ReadOperation operation) => + collection.doc(operation.itemId).get().then((snapshot) { + if (!snapshot.exists) { + return ReadSuccess(null, details: operation.details); + } + return ReadSuccess( + bindings.fromJson( + cleanData(snapshot.data() ?? {})..addAll({'id': snapshot.id}), + ), + details: operation.details, + ); + }); + + @override + Future> getByIds(ReadByIdsOperation operation) => + _guarded(() => _getByIds(operation), operation); + + Future> _getByIds(ReadByIdsOperation operation) async { + if (operation.itemIds.length <= 30) { + return collection + .where(FieldPath.documentId, whereIn: operation.itemIds) + .get() + .then((snapshot) => _processSnapshotDocs(snapshot.docs, operation)); + } + + final chunks = operation.itemIds.toList().chunks(30).toList(); + final snapshots = await Future.wait( + chunks.map( + (chunk) => collection.where(FieldPath.documentId, whereIn: chunk).get(), + ), + ); + + final allDocs = snapshots.expand((s) => s.docs).toList(); + return _processSnapshotDocs(allDocs, operation); + } + + ReadListResult _processSnapshotDocs( + List> docs, + ReadByIdsOperation operation, + ) { + final items = docs + .map( + (doc) => bindings.fromJson( + cleanData(doc.data())..addAll({'id': doc.id}), + ), + ) + .toList(); + final missingIds = operation.itemIds.toSet().difference( + docs.map((doc) => doc.id).toSet(), + ); + return ReadListResult.fromList( + items, + operation.details, + missingIds, + bindings.getId, + ); + } + + @override + Future> getItems(ReadListOperation operation) => + _guarded(() => _getItems(operation), operation); + + Future> _getItems(ReadListOperation operation) { + Query query = collection; + if (operation.details.filter != null) { + if (operation.details.filter is! FirestoreFilter) { + throw UnsupportedError( + 'Filter ${operation.details.filter.runtimeType} is not supported', + ); + } + query = (operation.details.filter! as FirestoreFilter).apply(query); + } + return query.get().then((snapshot) { + return ReadListResult.fromList( + snapshot.docs + .map( + (doc) => bindings.fromJson( + cleanData(doc.data())..addAll({'id': doc.id}), + ), + ) + .toList(), + operation.details, + {}, + bindings.getId, + ); + }); + } + + @override + Future> setItem(WriteOperation operation) => + _guarded(() => _setItem(operation), operation); + + Future> _setItem(WriteOperation operation) { + final existingId = bindings.getId(operation.item); + var dataToWrite = bindings.toJson(operation.item)..remove('id'); + + // Apply server timestamps for updates + for (final field in onUpdateServerTimestampFields) { + dataToWrite[field] = FieldValue.serverTimestamp(); + } + if (existingId == null || operation.details.forceInsert) { + // Apply server timestamps for create + for (final field in onCreateServerTimestampFields) { + dataToWrite[field] = FieldValue.serverTimestamp(); + } + } + + dataToWrite = cleanDataForWrite(dataToWrite); + + if (existingId == null && !operation.details.forceInsert) { + return collection.add(dataToWrite).then( + (doc) async { + final snapshot = await doc.get(); + return WriteSuccess( + bindings.fromJson( + cleanData(snapshot.data() ?? {})..addAll({'id': snapshot.id}), + ), + details: operation.details, + ); + }, + ); + } + + if (existingId == null && operation.details.forceInsert) { + throw UnsupportedError('Cannot force insert with no id'); + } + + final snapshot = collection.doc(existingId); + final writeFuture = operation.details.forceInsert + ? snapshot.set(dataToWrite) + : snapshot.update(dataToWrite); + + return writeFuture.then( + (_) { + return WriteSuccess( + bindings.fromJson(bindings.toJson(operation.item)), + details: operation.details, + ); + }, + ); + } + + @override + Future> setItems(WriteListOperation operation) => + throw UnimplementedError(); + + @override + Future> delete(DeleteOperation operation) { + return _guarded(() => _delete(operation), operation); + } + + Future> _delete(DeleteOperation operation) { + return collection + .doc(operation.itemId) + .delete() + .then( + (_) => DeleteSuccess(operation.details), + ); + } + + @override + SourceType sourceType = SourceType.remote; + + @override + Stream> watch(ReadOperation operation) => + _guardedSync(() => _watch(operation), operation); + + Stream> _watch(ReadOperation operation) { + return collection.doc(operation.itemId).snapshots().map((snapshot) { + if (!snapshot.exists) { + return ReadSuccess(null, details: operation.details); + } + return ReadSuccess( + bindings.fromJson( + cleanData(snapshot.data() ?? {})..addAll({'id': snapshot.id}), + ), + details: operation.details, + ); + }); + } + + @override + Stream> watchByIds(ReadByIdsOperation operation) => + _guardedSync(() => _watchByIds(operation), operation); + + Stream> _watchByIds(ReadByIdsOperation operation) { + if (operation.itemIds.length <= 30) { + return collection + .where(FieldPath.documentId, whereIn: operation.itemIds) + .snapshots() + .map((snapshot) => _processSnapshotDocs(snapshot.docs, operation)); + } + + final chunks = operation.itemIds.toList().chunks(30).toList(); + final streams = chunks.map( + (chunk) => collection + .where(FieldPath.documentId, whereIn: chunk) + .snapshots() + .map((s) => s.docs), + ); + + final streamsList = streams.toList(); + return streamsList.first + .combineLatestAll(streamsList.skip(1)) + .map( + (docsList) => _processSnapshotDocs( + docsList.expand((docs) => docs).toList(), + operation, + ), + ); + } + + @override + Stream> watchList(ReadListOperation operation) => + _guardedSync(() => _watchList(operation), operation); + + Stream> _watchList(ReadListOperation operation) { + Query query = collection; + if (operation.details.filter != null) { + if (operation.details.filter is! FirestoreFilter) { + throw UnsupportedError( + 'Filter ${operation.details.filter.runtimeType} is not supported', + ); + } + query = (operation.details.filter! as FirestoreFilter).apply(query); + } + return query.snapshots().map((snapshot) { + return ReadListResult.fromList( + snapshot.docs + .map( + (doc) => bindings.fromJson( + cleanData(doc.data())..addAll({'id': doc.id}), + ), + ) + .toList(), + operation.details, + {}, + bindings.getId, + ); + }); + } + + /// Converts Firebase [Timestamp] values into Dart [DateTime]s. + static Json cleanData(Json data) { + if (data.isEmpty) { + return data; + } + + Json? cleaned; + + for (final entry in data.entries) { + final value = entry.value; + final cleanedValue = _cleanValue(value); + + if (!identical(cleanedValue, value)) { + cleaned ??= Map.from(data); + cleaned[entry.key] = cleanedValue; + } + } + + return cleaned ?? data; + } + + static Object? _cleanValue(Object? value) { + if (value is Timestamp) { + return value.toDate().toUtc().toIso8601String(); + } + if (value is Map) { + return cleanData(value); + } + if (value is List) { + List? newList; + for (var i = 0; i < value.length; i++) { + final item = value[i]; + final cleanedItem = _cleanValue(item); + if (!identical(cleanedItem, item)) { + newList ??= List.from(value); + newList[i] = cleanedItem; + } + } + return newList ?? value; + } + return value; + } + + /// Converts Dart [DateTime]s into Firebase [Timestamp] values. + static Json cleanDataForWrite(Json data) { + if (data.isEmpty) { + return data; + } + + Json? cleaned; + + for (final entry in data.entries) { + final value = entry.value; + final cleanedValue = _cleanValueForWrite(value); + + if (!identical(cleanedValue, value)) { + cleaned ??= Map.from(data); + cleaned[entry.key] = cleanedValue; + } + } + + return cleaned ?? data; + } + + static Object? _cleanValueForWrite(Object? value) { + if (value is DateTime) { + return Timestamp.fromDate(value); + } + if (value is String) { + final iso8601Regex = RegExp( + r'^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z)?$', + ); + if (iso8601Regex.hasMatch(value)) { + final parsed = DateTime.tryParse(value); + if (parsed != null) { + return Timestamp.fromDate(parsed); + } + } + } + if (value is Map) { + return cleanDataForWrite(value); + } + if (value is List) { + List? newList; + for (var i = 0; i < value.length; i++) { + final item = value[i]; + final cleanedItem = _cleanValueForWrite(item); + if (!identical(cleanedItem, item)) { + newList ??= List.from(value); + newList[i] = cleanedItem; + } + } + return newList ?? value; + } + return value; + } + + Future _guarded(Future Function() fn, Operation operation) async { + try { + return await fn(); + } on FirebaseException catch (e) { + _handleFirebaseException(e, operation); + rethrow; + } on Exception catch (e) { + final description = _describeOperation(operation); + _log.severe('Uncaught error: $e. $description'); + rethrow; + } + } + + R _guardedSync(R Function() fn, Operation operation) { + try { + return fn(); + } on FirebaseException catch (e) { + _handleFirebaseException(e, operation); + rethrow; + } on Exception catch (e) { + final description = _describeOperation(operation); + _log.severe('Uncaught error: $e. $description'); + rethrow; + } + } + + void _handleFirebaseException( + FirebaseException e, + Operation operation, + ) { + final description = _describeOperation(operation); + switch (e.code) { + case 'permission-denied': + _log.severe('Permission denied: ${e.message}. $description'); + case 'not-found': + _log.severe('Not found: ${e.message}. $description'); + case 'unavailable': + _log.severe('The service is currently unavailable (offline?).'); + case 'unauthenticated': + _log.severe('User must be logged in to perform this action.'); + case 'deadline-exceeded': + _log.severe('The operation took too long to complete.'); + default: + _log.severe( + 'Uncaught Firebase error ${e.code} :: ${e.message}. $description', + ); + } + } + + String _describeOperation(Operation operation) { + final collectionName = bindings.getListUrl().path; + return switch (operation) { + ReadOperation() => + 'Failed to read $collectionName/${operation.itemId}', + ReadByIdsOperation() => + 'Failed to read $collectionName/${operation.itemIds}', + ReadListOperation() => 'Failed to read $collectionName', + WriteOperation() => + 'Failed to write $collectionName/${bindings.getId(operation.item)}', + WriteListOperation() => 'Failed to write $collectionName', + DeleteOperation() => + 'Failed to delete $collectionName/${operation.itemId}', + }; + } +} + +extension _ListChunkX on List { + Iterable> chunks(int size) sync* { + for (var i = 0; i < length; i += size) { + yield sublist(i, i + size > length ? length : i + size); + } + } +} diff --git a/genlatte/genlatte-ui/lib/src/sources/sources.dart b/genlatte/genlatte-ui/lib/src/sources/sources.dart new file mode 100644 index 0000000..e937776 --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/sources/sources.dart @@ -0,0 +1,6 @@ +// Copyright 2014 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 'firestore_filters.dart'; +export 'firestore_source.dart'; diff --git a/genlatte/genlatte-ui/lib/src/widgets/CONTEXT.md b/genlatte/genlatte-ui/lib/src/widgets/CONTEXT.md new file mode 100644 index 0000000..e920f5f --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/widgets/CONTEXT.md @@ -0,0 +1,27 @@ +# Common Widgets + +**Purpose:** +Hosts shared UI primitives and highly complex layout mathematics structures that are reused globally across the application. + +**Detailed File Overviews:** + +- `widgets.dart`: + - **Description**: Barrel export file. + +- `buttons.dart` / `chevron_button.dart` / `dynamic_button_layout.dart`: + - **Description**: Custom Interactive elements. + - **Core Logic**: Reusable touch targets overriding defaults to provide deep haptics, stylized borders, or specific branding physics (e.g. `ChevronAnimatedButton`). + +- `genlatte_scaffold.dart.dart` / `configuration_card.dart` / `footer.dart`: + - **Description**: Core Structural components. + - **Core Logic**: The root branding skeletons framing the views, providing headers, progress indicators, or standard card backgrounds. + +- `design_scalar.dart` / `responsive_sized_box.dart` / `layout_provider.dart`: + - **Description**: Scaling Math logic. + - **Core Logic**: Complex `LayoutBuilder` implementations acting as alternatives to standard media queries, capable of perfectly scaling Typography and Widget padding based on percentage of available constraints (critical for resizing between Mobile, Tablet, and weird aspect ratio Kiosk monitors). + +- `triple_tap.dart`: + - **Description**: Advanced gesture recognition used to hide developer tools intentionally behind complex physical interactions. + +**Dependencies/Relationships:** +- Consumed widely throughout `genlatte-ui/lib/src/screens/`. diff --git a/genlatte/genlatte-ui/lib/src/widgets/animated_gradient.dart b/genlatte/genlatte-ui/lib/src/widgets/animated_gradient.dart new file mode 100644 index 0000000..55788e4 --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/widgets/animated_gradient.dart @@ -0,0 +1,107 @@ +// Copyright 2014 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_animate/flutter_animate.dart'; +import 'package:genlatte/src/screens/app/theme.dart'; + +class AnimatedGradientBackground extends StatelessWidget { + const AnimatedGradientBackground({super.key}); + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + // Base background + Container(color: AppColors.white), + + // Animated Blobs + _Blob( + color: AppColors.googleIntroBlue.withValues(alpha: 0.3), + begin: Alignment.topLeft, + end: Alignment.bottomRight, + duration: 10.seconds, + ), + _Blob( + color: AppColors.googleIntroRed.withValues(alpha: 0.2), + begin: Alignment.topRight, + end: Alignment.bottomLeft, + duration: 8.seconds, + delay: 1.seconds, + ), + _Blob( + color: AppColors.googleIntroGreen.withValues(alpha: 0.2), + begin: Alignment.bottomLeft, + end: Alignment.topRight, + duration: 12.seconds, + delay: 2.seconds, + ), + _Blob( + color: AppColors.latteArtGold.withValues(alpha: 0.2), + begin: Alignment.centerRight, + end: Alignment.centerLeft, + duration: 15.seconds, + delay: 3.seconds, + ), + + // Blur effect to make it look like a mesh gradient + const IgnorePointer( + child: SizedBox.expand( + child: DecoratedBox( + decoration: BoxDecoration( + color: Colors.transparent, + ), + ), + ), + ), + ], + ); + } +} + +class _Blob extends StatelessWidget { + const _Blob({ + required this.color, + required this.begin, + required this.end, + required this.duration, + this.delay = Duration.zero, + }); + + final Color color; + final Alignment begin; + final Alignment end; + final Duration duration; + final Duration delay; + + @override + Widget build(BuildContext context) { + return Animate( + onPlay: (controller) => controller.repeat(reverse: true), + delay: delay, + ).custom( + duration: duration, + builder: (context, value, child) { + final alignment = Alignment.lerp(begin, end, value)!; + return Align( + alignment: alignment, + child: Container( + width: 400, + height: 400, + decoration: BoxDecoration( + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: color, + blurRadius: 150, + spreadRadius: 100, + ), + ], + ), + ), + ); + }, + ); + } +} diff --git a/genlatte/genlatte-ui/lib/src/widgets/buttons.dart b/genlatte/genlatte-ui/lib/src/widgets/buttons.dart new file mode 100644 index 0000000..8c3cf01 --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/widgets/buttons.dart @@ -0,0 +1,377 @@ +// Copyright 2014 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:math'; + +import 'package:genlatte/src/screens/app/app.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +/// Standard application button with slightly rounded corners, a thin border, +/// and a transparent background. +class GenLatteOutlinedButton extends StatelessWidget { + const GenLatteOutlinedButton._({ + required this.label, + required this.onPressed, + required _ButtonLuminence luminence, + required this.size, + super.key, + }) : _luminence = luminence; + + /// Creates a new [GenLatteOutlinedButton] with a white border, suitable to + /// pair with dark backgrounds. + factory GenLatteOutlinedButton.light({ + required String label, + required VoidCallback? onPressed, + GenLatteButtonSize size = .normal, + Key? key, + }) { + return GenLatteOutlinedButton._( + label: label, + onPressed: onPressed, + luminence: .light, + size: size, + key: key, + ); + } + + /// Creates a new [GenLatteOutlinedButton] with a red border, suitable for + /// urgent alerts. + factory GenLatteOutlinedButton.red({ + required String label, + required VoidCallback? onPressed, + GenLatteButtonSize size = .normal, + Key? key, + }) { + return GenLatteOutlinedButton._( + label: label, + onPressed: onPressed, + luminence: .red, + size: size, + key: key, + ); + } + + /// Creates a new [GenLatteOutlinedButton] with a dark border, suitable to + /// pair with light backgrounds. + factory GenLatteOutlinedButton.dark({ + required String label, + required VoidCallback? onPressed, + GenLatteButtonSize size = .normal, + Key? key, + }) { + return GenLatteOutlinedButton._( + label: label, + onPressed: onPressed, + luminence: .dark, + size: size, + key: key, + ); + } + + /// Text to show in the button. + final String label; + + /// Callback to invoke when the button is pressed. + final VoidCallback? onPressed; + + /// {@macro _ButtonLuminence} + final _ButtonLuminence _luminence; + + /// {@macro _ButtonSize} + final GenLatteButtonSize size; + + @override + Widget build(BuildContext context) { + final color = switch (_luminence) { + .light => const Color(0xFFFFFFFF), + .dark => const Color(0xFF000000), + .red => const Color(0xFFD32F2F), + }; + final padding = size == .normal + ? const EdgeInsets.symmetric(horizontal: 16, vertical: 12) + : const EdgeInsets.symmetric(horizontal: 16, vertical: 16); + return Button( + onPressed: onPressed, + style: const ButtonStyle.outline() + .withPadding(padding: padding) + .withBackgroundColor( + color: Colors.transparent, + hoverColor: Colors.transparent, + focusColor: Colors.transparent, + disabledColor: Colors.transparent, + ) + .withBorder( + border: Border.all(color: color), + hoverBorder: Border.all(color: color), + focusBorder: Border.all(color: color), + disabledBorder: Border.all(color: color), + ) + .withBorderRadius( + borderRadius: BorderRadius.circular(12), + hoverBorderRadius: BorderRadius.circular(12), + focusBorderRadius: BorderRadius.circular(12), + disabledBorderRadius: BorderRadius.circular(12), + ), + // There is a problem with the `withPadding()` method. The + // plumbing which eventually resolves button padding + child: Text( + label, + style: TextStyle( + color: color, + fontSize: switch (size) { + .normal => 14, + .large => 16, + }, + ), + ), + ); + } +} + +/// Similar to an outline button, but used for configuring a coffee or the +/// generated art which will live atop it. +class GenLatteConfigurationButton extends StatelessWidget { + /// Creates a new [GenLatteConfigurationButton]. + const GenLatteConfigurationButton({ + required this.label, + required this.isSelected, + required this.onPressed, + required this.isTight, + required this.uiScale, + super.key, + }); + + /// Text to show in the button. + final String label; + + /// Callback to invoke when the button is pressed. + final VoidCallback onPressed; + + /// Whether this button is selected. + final bool isSelected; + + /// If true, everything is a little smaller. + final bool isTight; + + /// Scaling up or down of the UI element. + final double uiScale; + + /// UI padding for tight buttons (before uiScaling). + static const tightPadding = EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ); + + /// UI padding for normal buttons (before uiScaling). + static const normalPadding = EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ); + + static final _tightBorderRadius = BorderRadius.circular(6); + static final _normalBorderRadius = BorderRadius.circular(8); + + @override + Widget build(BuildContext context) { + final borderRadius = + (isTight ? _tightBorderRadius : _normalBorderRadius) * uiScale; + final padding = (isTight ? tightPadding : normalPadding) * uiScale; + final backgroundColor = isSelected ? AppColors.black : Colors.transparent; + + // Default values are 1px black. + final border = Border.all(); + + return Button( + onPressed: onPressed, + style: const ButtonStyle.outline() + .withBackgroundColor( + color: backgroundColor, + hoverColor: backgroundColor, + focusColor: backgroundColor, + disabledColor: backgroundColor, + ) + .withBorder( + border: border, + hoverBorder: border, + focusBorder: border, + disabledBorder: border, + ) + .withBorderRadius( + borderRadius: borderRadius, + hoverBorderRadius: borderRadius, + focusBorderRadius: borderRadius, + disabledBorderRadius: borderRadius, + ) + .withPadding( + padding: padding, + hoverPadding: padding, + focusPadding: padding, + disabledPadding: padding, + ), + // There is a problem with the `withPadding()` method. The + // plumbing which eventually resolves button padding + child: Text( + label, + style: TextStyle( + color: isSelected ? Colors.white : const Color(0xFF000000), + fontSize: 14 * uiScale, + ), + ), + ); + } +} + +/// Standard application button with slightly rounded corners and a filled white +/// background. +class GenLatteFilledButton extends StatelessWidget { + /// Creates a new [GenLatteFilledButton]. + const GenLatteFilledButton({ + required this.onPressed, + this.label, + this.icon, + this.trailingIcon, + this.size = .normal, + super.key, + }) : assert( + label != null || icon != null || trailingIcon != null, + 'Must provide either a label or an icon or trailingIcon.', + ); + + /// Text to show in the button. + final String? label; + + /// Icon to show in the button. + final IconData? icon; + + /// Icon to show after the label. + final IconData? trailingIcon; + + /// Callback to invoke when the button is pressed. + final VoidCallback onPressed; + + /// {@macro _ButtonSize} + final GenLatteButtonSize size; + + /// Maximum and default size of a button; used to scale down UI values when + /// this button finds itself in tighter quarters. + static const _defaultSize = 60; + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + final double uiScale = + (min(constraints.maxWidth, constraints.maxHeight) / _defaultSize) + .clamp(0.5, 1); + final padding = size == .normal + ? EdgeInsets.symmetric( + horizontal: 16 * uiScale, + vertical: 12 * uiScale, + ) + : EdgeInsets.symmetric( + horizontal: 16 * uiScale, + vertical: 16 * uiScale, + ); + final labelChild = label != null + ? Text( + label!, + style: TextStyle( + color: const Color(0xFF000000), + fontSize: 16 * uiScale, + ), + ) + : null; + final iconChild = icon != null + ? Icon(icon, size: 24 * uiScale, color: const Color(0xFF000000)) + : null; + + final trailingIconChild = trailingIcon != null + ? Icon( + trailingIcon, + size: 24 * uiScale, + color: const Color(0xFF000000), + ) + : null; + + // If we only have an icon, force an outright circular shape + final ButtonShape shape = icon != null && label == null + ? .circle + : .rectangle; + + final border = shape == .rectangle + ? Border.all(color: const Color(0x00000000)) + : null; + + final borderRadius = shape == .rectangle + ? BorderRadius.circular(999) + : null; + + var buttonStyle = ButtonStyle.outline(shape: shape) + .withPadding(padding: padding) + .withBackgroundColor(color: const Color(0xFFFFFFFF)); + + buttonStyle = buttonStyle.withBorder( + border: border, + hoverBorder: border, + focusBorder: border, + disabledBorder: border, + ); + + buttonStyle = buttonStyle.withBorderRadius( + borderRadius: borderRadius, + hoverBorderRadius: borderRadius, + focusBorderRadius: borderRadius, + disabledBorderRadius: borderRadius, + ); + + return Button( + onPressed: onPressed, + style: buttonStyle, + // This key is needed to force the button to rebuild when the shape + // changes. + // https://github.com/sunarya-thito/shadcn_flutter/issues/404 + key: ValueKey('button-$shape'), + child: Row( + mainAxisSize: .min, + children: [ + ?iconChild, + if (iconChild != null && labelChild != null) // + SizedBox(width: 8 * uiScale), + ?labelChild, + if (trailingIcon != null && labelChild != null) // + SizedBox(width: 8 * uiScale), + ?trailingIconChild, + ], + ), + ); + }, + ); + } +} + +/// {@template _ButtonLuminence} +/// Color flavor of a button. +/// {@endtemplate} +enum _ButtonLuminence { + /// Indicates a primarily white button, suitable for dark backgrounds. + light, + + /// Indicates a primarily dark button, suitable for light luminence + /// backgrounds. + dark, + + /// Indicates a red button, suitable for urgent alerts. + red, +} + +/// {@template ButtonSize} +/// Size of a button. +/// {@endtemplate} +enum GenLatteButtonSize { + /// Default button sizing + normal, + + /// Extra padding, for when it really matters + large, +} diff --git a/genlatte/genlatte-ui/lib/src/widgets/chevron_button.dart b/genlatte/genlatte-ui/lib/src/widgets/chevron_button.dart new file mode 100644 index 0000000..6e0b738 --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/widgets/chevron_button.dart @@ -0,0 +1,585 @@ +// Copyright 2014 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:math'; + +import 'package:flutter/services.dart'; +import 'package:genlatte/src/screens/app/theme.dart'; +import 'package:genlatte/src/widgets/widgets.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +/// The orientation of the chevron button. +enum ChevronButtonType { + /// A tall chevron button, suitable for rows within landscape UIs. + vertical, + + /// A short chevron button, suitable for columns within portrait UIs. + flat, +} + +/// A button that is shaped like a chevron pointing to the right. The two +/// shapes, `vertical` and `flat`, are for tablets/desktop and mobile +/// respectively. +class ChevronButton extends StatefulWidget { + /// Generic constructor for a [ChevronButton]. + ChevronButton._({ + required this.builder, + required ChevronButtonType type, + required this.color, + required this.width, + required this.height, + required this.onPressed, + required this.scale, + required this.textColor, + required this.textPadding, + this.borderColor, + super.key, + }) { + painterBuilder = type == ChevronButtonType.vertical + ? (Color color, Color? borderColor) => + _verticalPainter(color, borderColor, scale) + : (Color color, Color? borderColor) => + _flatPainter(color, borderColor, scale); + } + + /// Creates a [ChevronButton] with vertical orientation at the given scale. + factory ChevronButton.vertical({ + required Widget Function(BuildContext, double, Color?) builder, + VoidCallback? onPressed, + double scale = 1.0, + Color? color, + Color? borderColor, + Color? textColor, + Key? key, + }) => ChevronButton._( + builder: builder, + width: _VerticalChevronPainter._defaultWidth * scale, + height: _VerticalChevronPainter._defaultHeight * scale, + color: color ?? AppColors.chevronYellow, + borderColor: borderColor, + onPressed: onPressed, + scale: scale, + textColor: textColor, + textPadding: EdgeInsets.only( + left: (_VerticalChevronPainter._defaultArrowDepth * scale) * 0.98, + ), + type: .vertical, + key: key, + ); + + /// Creates a [ChevronButton] with flat orientation and the given scale. + factory ChevronButton.flat({ + required Widget Function(BuildContext, double, Color?) builder, + VoidCallback? onPressed, + double scale = 1.0, + Color? color, + Color? borderColor, + Color? textColor, + Key? key, + }) => ChevronButton._( + builder: builder, + width: _FlatChevronPainter._defaultWidth * scale, + height: _FlatChevronPainter._defaultHeight * scale, + color: color ?? AppColors.chevronYellow, + borderColor: borderColor, + onPressed: onPressed, + scale: scale, + textColor: textColor, + textPadding: EdgeInsets.zero, + type: .flat, + key: key, + ); + + static CustomPainter _verticalPainter( + Color color, + Color? borderColor, + double scale, + ) => _VerticalChevronPainter( + color: color, + borderColor: borderColor, + radius: _VerticalChevronPainter._defaultCornerRadius * scale, + depth: _VerticalChevronPainter._defaultArrowDepth * scale, + scale: scale, + ); + + static CustomPainter _flatPainter( + Color color, + Color? borderColor, + double scale, + ) => _FlatChevronPainter( + color: color, + borderColor: borderColor, + tailWidth: _FlatChevronPainter._defaultTailWidth * scale, + arrowWidth: _FlatChevronPainter._defaultArrowWidth * scale, + scale: scale, + ); + + /// Descendant builder with UI scaling. + final Widget Function( + BuildContext context, + double textScale, + Color? textColor, + ) + builder; + + /// Final width of the chevron. + final double width; + + /// Final height of the chevron. + final double height; + + /// Color of the chevron. + final Color color; + + /// Optional border color that defaults to the main color if omitted. + final Color? borderColor; + + /// UI scalar. + final double scale; + + /// Optional text color that defaults to the main color if omitted. + final Color? textColor; + + /// Special padding for the button text to make it look centered. + final EdgeInsets textPadding; + + /// Callback when the button is pressed. + final VoidCallback? onPressed; + + /// That which draws the button itself. + late final CustomPainter Function(Color, Color?) painterBuilder; + + @override + State createState() => _ChevronButtonState(); +} + +class _ChevronButtonState extends State { + bool _isHovered = false; + bool _isFocused = false; + + @override + Widget build(BuildContext context) { + // Darken the color slightly when hovered or focused + final effectiveColor = (widget.onPressed == null) + ? const Color(0xFF3D3D3D) + : (_isHovered || _isFocused) + ? Color.lerp(widget.color, Colors.black, 0.1)! + : widget.color; + + final effectiveBorderColor = widget.borderColor == null + ? null + : (widget.onPressed == null) + ? const Color(0xFF3D3D3D) + : (_isHovered || _isFocused) + ? Color.lerp(widget.borderColor, Colors.black, 0.1)! + : widget.borderColor; + + return Semantics( + button: true, + enabled: widget.onPressed != null, + onTap: widget.onPressed, + child: FocusableActionDetector( + mouseCursor: SystemMouseCursors.click, + onShowFocusHighlight: (value) => setState(() => _isFocused = value), + onShowHoverHighlight: (value) => setState(() => _isHovered = value), + actions: { + ActivateIntent: CallbackAction( + onInvoke: (_) => widget.onPressed?.call(), + ), + }, + shortcuts: const { + SingleActivator(LogicalKeyboardKey.enter): ActivateIntent(), + SingleActivator(LogicalKeyboardKey.space): ActivateIntent(), + }, + child: GestureDetector( + onTap: widget.onPressed, + child: SizedBox( + width: widget.width, + height: widget.height, + child: Stack( + children: [ + Positioned.fill( + child: CustomPaint( + painter: widget.painterBuilder( + effectiveColor, + effectiveBorderColor, + ), + ), + ), + Center( + child: Padding( + padding: widget.textPadding, + child: widget.builder( + context, + widget.scale, + widget.textColor, + ), + ), + ), + ], + ), + ), + ), + ), + ); + } +} + +class _VerticalChevronPainter extends CustomPainter { + _VerticalChevronPainter({ + required this.color, + required this.radius, + required this.depth, + required this.scale, + this.borderColor, + }); + + final Color color; + final Color? borderColor; + final double radius; + final double depth; + final double scale; + + static const double _defaultWidth = 214; + static const double _defaultHeight = 405; + static const double _aspectRatio = _defaultWidth / _defaultHeight; + static const Size _defaultSize = Size(_defaultWidth, _defaultHeight); + static const double _defaultCornerRadius = 20; + static const double _defaultArrowDepth = 27; + + @override + void paint(Canvas canvas, Size size) { + final paint = Paint() + ..color = color + ..style = PaintingStyle.fill + ..isAntiAlias = true; + + final w = size.width; + final h = size.height; + + // Calculate the vertical center for the chevron tip + final centerY = h / 2; + + // We add a tiny smoothing curve at the arrow tip so it's not razor sharp + const tipRadius = 4.0; + + final path = Path() + // 1. Start at Top-Left (after the corner) + // The shape is indented on the left, so x starts at 0 and goes IN by + // 'depth' + ..moveTo(0, radius) + // 2. Top-Left Corner + // We use ArcToPoint to ensure a perfect circular corner regardless of + // scale + ..arcToPoint( + Offset(radius, 0), + radius: Radius.circular(radius), + ) + // 3. Top Edge + // Goes to the right, but stops short for the right corner. + // The right side is shifted "out" by `depth`. + // The main body width is (w - depth). + ..lineTo(w - depth - radius, 0) + // 4. Top-Right Corner + ..arcToPoint( + Offset(w - depth, radius), + radius: Radius.circular(radius), + ) + // 5. Right Side (Top Half) -> Going OUT to the tip + // We curve slightly to the tip at (w, centerY) + ..lineTo(w - 0.5, centerY - tipRadius) + // 6. Right Tip (Small curve for the point) + ..quadraticBezierTo(w, centerY, w - 0.5, centerY + tipRadius) + // 7. Right Side (Bottom Half) -> Going IN to bottom corner + ..lineTo(w - depth, h - radius) + // 8. Bottom-Right Corner + ..arcToPoint( + Offset(w - depth - radius, h), + radius: Radius.circular(radius), + ) + // 9. Bottom Edge + ..lineTo(radius, h) + // 10. Bottom-Left Corner + ..arcToPoint( + Offset(0, h - radius), + radius: Radius.circular(radius), + ) + // 11. Left Side (Bottom Half) -> Going IN to the indentation + ..lineTo(depth, centerY + tipRadius) + // 12. Left Indent Tip (Small internal curve) + ..quadraticBezierTo(depth - 1.0, centerY, depth, centerY - tipRadius) + // 13. Left Side (Top Half) -> Going OUT to start + ..lineTo(0, radius) + ..close(); + + canvas.drawPath(path, paint); + + final finalBorderColor = borderColor ?? color; + final strokePaint = Paint() + ..color = finalBorderColor + ..style = PaintingStyle.stroke + ..strokeWidth = 2.0 * scale + ..isAntiAlias = true; + canvas.drawPath(path, strokePaint); + } + + @override + bool shouldRepaint(covariant _VerticalChevronPainter oldDelegate) { + return oldDelegate.color != color || + oldDelegate.borderColor != borderColor || + oldDelegate.radius != radius || + oldDelegate.depth != depth || + oldDelegate.scale != scale; + } +} + +class _FlatChevronPainter extends CustomPainter { + _FlatChevronPainter({ + required this.color, + required this.tailWidth, + required this.arrowWidth, + required this.scale, + this.borderColor, + }); + final Color color; + final Color? borderColor; + final double tailWidth; + final double arrowWidth; + final double scale; + + // Original SVG dimensions + static const double _defaultHeight = 90; + static const double _defaultWidth = 234; + static const double _aspectRatio = _defaultWidth / _defaultHeight; + static const Size _defaultSize = Size(_defaultWidth, _defaultHeight); + + // Original feature widths (calculated from SVG coordinates) + // Left tail is approx 15px wide (x=0 to x=15) + static const double _defaultTailWidth = 15; + // Right arrow is approx 38px wide (from flat edge x=196 to tip x=234) + static const double _defaultArrowWidth = 38; + + @override + void paint(Canvas canvas, Size size) { + final paint = Paint() + ..color = color + ..style = PaintingStyle.fill + ..isAntiAlias = true; + + final w = size.width; + final h = size.height; + final s = scale; + + // We calculate the "flat" part start point on the right + final rightFlatX = w - arrowWidth; + + final path = Path() + // --- 1. Top Edge --- + // Start after the tail (x = tailWidth) + ..moveTo(tailWidth, 0) + // Draw straight line to the start of the arrow + ..lineTo(rightFlatX, 0) + // --- 2. Right Arrow Head --- + // All coordinates are relative to `rightFlatX`. + // We use the original SVG control points multiplied by `s` (scale). + // Top curve of arrow + ..cubicTo( + rightFlatX + (4.7 * s), + 0, + rightFlatX + (9.1 * s), + 2.2 * s, + rightFlatX + (12.0 * s), + 6.0 * s, + ) + // Line to tip approach + ..lineTo(rightFlatX + (34.4 * s), 36.0 * s) + // The Tip Rounding + ..cubicTo( + rightFlatX + (38.4 * s), + 41.3 * s, + rightFlatX + (38.4 * s), + 48.6 * s, + rightFlatX + (34.4 * s), + 54.0 * s, + ) + // Line back from tip + ..lineTo(rightFlatX + (12.0 * s), 84.0 * s) + // Bottom curve of arrow + ..cubicTo( + rightFlatX + (9.1 * s), + 87.8 * s, + rightFlatX + (4.7 * s), + 90.0 * s, + rightFlatX, + 90.0 * s, + ) + // --- 3. Bottom Edge --- + ..lineTo(tailWidth, h) + // --- 4. Left Tail (The Wave) --- + // We draw from bottom (h) up to top (0). + // The X coordinates are relative to 0. + // Bottom wave segment + ..cubicTo(2.7 * s, 90.0 * s, -4.4 * s, 76.0 * s, 3.0 * s, 66.0 * s) + // Line Inward + ..lineTo(12.0 * s, 54.0 * s) + // Middle wave segment + ..cubicTo(16.0 * s, 48.6 * s, 16.0 * s, 41.3 * s, 12.0 * s, 36.0 * s) + // Line Outward + ..lineTo(3.0 * s, 24.0 * s) + // Top wave segment + ..cubicTo(-4.4 * s, 14.0 * s, 2.7 * s, 0, tailWidth, 0) + ..close(); + + canvas.drawPath(path, paint); + + final finalBorderColor = borderColor ?? color; + final strokePaint = Paint() + ..color = finalBorderColor + ..style = PaintingStyle.stroke + ..strokeWidth = 2.0 * s + ..isAntiAlias = true; + canvas.drawPath(path, strokePaint); + } + + @override + bool shouldRepaint(covariant _FlatChevronPainter oldDelegate) { + return oldDelegate.color != color || + oldDelegate.borderColor != borderColor || + oldDelegate.scale != scale || + oldDelegate.tailWidth != tailWidth; + } +} + +/// Delegates to a [ChevronButton] that adapts to the current orientation. +class ResponsiveChevronButton extends StatelessWidget { + /// Instantiates a [ResponsiveChevronButton]. + const ResponsiveChevronButton({ + required this.onPressed, + required this.text, + this.color, + this.borderColor, + this.textColor, + this.scale, + this.style, + super.key, + }); + + /// CLick handler. + final VoidCallback? onPressed; + + /// The color of the button. + final Color? color; + + /// Optional border color that defaults to the main color if omitted. + final Color? borderColor; + + /// Optional text color that defaults to the main color if omitted. + final Color? textColor; + + /// Button text. + final String text; + + /// Scalar for the button. + final double? scale; + + /// Button style. + final ChevronButtonType? style; + + @override + Widget build(BuildContext context) { + return LayoutProvider.builder( + builder: (context, layoutInfo) { + return (style == .vertical) || + (style != .flat && layoutInfo.orientation.isPortrait) + ? ResponsiveSizedBox.builder( + aspectRatioClamp: ( + null, + _VerticalChevronPainter._aspectRatio, + null, + ), + maxSize: _VerticalChevronPainter._defaultSize, + builder: (context, Size size) { + final calculatedScale = + size.height / _VerticalChevronPainter._defaultHeight; + final finalScale = scale != null + ? min(scale!, calculatedScale) + : calculatedScale; + + // 200px width is about the min width before 5-character + // strings start to squish and wrap + final baseFontSize = 44 * (size.width / 200); + + return Center( + child: SizedBox.fromSize( + size: size * (scale ?? 1), + child: ChevronButton.vertical( + builder: (context, double textScale, Color? textColor) { + return WrappedText( + style: (context, theme) { + return theme.typography.h1.copyWith( + // h1 font size is coming as 60, but need + // smaller to fit nicely + fontSize: + baseFontSize * min(textScale, finalScale), + color: textColor, + ); + }, + child: Text(text), + ); + }, + color: color, + borderColor: borderColor, + textColor: textColor, + onPressed: onPressed, + scale: finalScale, + ), + ), + ); + }, + ) + : ResponsiveSizedBox.builder( + aspectRatioClamp: ( + null, + _FlatChevronPainter._aspectRatio, + null, + ), + maxSize: _FlatChevronPainter._defaultSize * (scale ?? 1), + builder: (context, Size size) { + final calculatedScale = + size.height / _FlatChevronPainter._defaultHeight; + final finalScale = scale != null + ? min(scale!, calculatedScale) + : calculatedScale; + return Center( + child: SizedBox.fromSize( + size: size, + child: ChevronButton.flat( + builder: + (context, double textScale, Color? textColor) // + => WrappedText( + style: (context, theme) => + theme.typography.h1.copyWith( + fontSize: + // h1 font size is coming as 60, but + // need about a 10% reduction to size + // this nicely + 54 * min(textScale, finalScale), + color: textColor, + ), + child: Text(text), + ), + color: color, + borderColor: borderColor, + textColor: textColor, + onPressed: onPressed, + scale: finalScale, + ), + ), + ); + }, + ); + }, + ); + } +} diff --git a/genlatte/genlatte-ui/lib/src/widgets/clipboard_value.dart b/genlatte/genlatte-ui/lib/src/widgets/clipboard_value.dart new file mode 100644 index 0000000..be05311 --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/widgets/clipboard_value.dart @@ -0,0 +1,80 @@ +// Copyright 2014 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:clipboard/clipboard.dart'; +import 'package:logging/logging.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +/// Renders a value which can be copied to the clipboard. +class ClipboardValue extends StatefulWidget { + /// Instantiates a [ClipboardValue]. + const ClipboardValue(this.value, {required this.label, super.key}); + + /// The value to be displayed and copied. + final String value; + + /// Semantic label. + final String label; + + @override + State createState() => _ClipboardValueState(); +} + +class _ClipboardValueState extends State { + @override + Widget build(BuildContext context) => SizedBox( + height: 48, + child: Row( + children: [ + Flexible( + child: Semantics( + label: 'Copy ${widget.label}', + child: Padding( + padding: const EdgeInsets.all(12), + child: GestureDetector( + onTap: () async { + try { + await FlutterClipboard.copy(widget.value); + if (!mounted) return; + showToast( + // ignore: use_build_context_synchronously + context: context, + builder: (context, overlay) { + return SurfaceCard( + child: Basic( + title: Text( + 'Copied ${widget.label}', + ), + ), + ); + }, + ); + } on Exception catch (e, st) { + Logger('ClipboardValue').severe( + 'Failed to copy ${widget.label}} ' + '${widget.value} to ' + 'clipboard', + e, + st, + ); + } + }, + child: const Icon(Icons.copy), + ), + ), + ), + ), + Flexible( + flex: 5, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text(widget.value).xSmall.mono, + ], + ), + ), + ], + ), + ); +} diff --git a/genlatte/genlatte-ui/lib/src/widgets/configuration_card.dart b/genlatte/genlatte-ui/lib/src/widgets/configuration_card.dart new file mode 100644 index 0000000..5f406e7 --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/widgets/configuration_card.dart @@ -0,0 +1,731 @@ +// Copyright 2014 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:math'; + +import 'package:genlatte/src/screens/app/theme.dart' show AppColors; +import 'package:genlatte/src/widgets/widgets.dart'; +import 'package:genlatte_data/models.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart' hide Button; +import 'package:text_responsive/text_responsive.dart'; + +/// Cosmetic theme for the card; determines the background color. +enum Flavor { + /// Beige, woody color. + wood, + + /// Light blue-green color. + seaFoam, + + /// I don't even know. I just work here. + weirdGreen; + + /// Background color of the card of this flavor. + Color get backgroundColor => switch (this) { + .wood => const Color(0xFFC9B787), + .seaFoam => const Color(0xFFB7EBE1), + .weirdGreen => const Color(0xFFD1DF90), + }; + + /// Loops through the flavors forever and ever and ever. + static Flavor forIndex(int index) { + return values[index % values.length]; + } +} + +/// Overall shape of the card. +enum CardShape { + /// 160 / 255 + flat, + + /// 335 / 255 + tall, + + /// Uses all available space + expanded; + + /// Height of this type of card under normal conditions. Smaller constraints + /// may end up changing this in practice if the widget is wrapped in a + /// [ResponsiveSizedBox] that provides smaller constraints. + double? get height => switch (this) { + .flat => 160, + .tall => 335, + .expanded => 400, + }; + + /// Width of this type of card under normal conditions. Smaller constraints + /// may end up changing this in practice if the widget is wrapped in a + /// [ResponsiveSizedBox] that provides smaller constraints. + double? get width => switch (this) { + .flat => 255, + .tall => 255, + .expanded => 745, + }; + + /// Aspect ratio of this card under normal conditions. + double get aspectRatio => width! / height!; + + /// Width and height packaged into a [Size] object. + Size get size => Size(width!, height!); +} + +/// A card that presents a latte art configuration question to the user, like, +/// "How crowded is this party?" or "What time of day is it?" +/// +/// If a [CardShape] is provided, then the resulting [ConfigurationCard] will +/// have an exact and predictable size. If no [CardShape] is provided, then the +/// [ConfigurationCard] will expand to fill all available space. +/// +/// The [ConfigurationCard] widget does not play well with unlimited constraints +/// and must be nestled within something that provides limited height and width. +/// +/// [T] is the type of the value returned by [onSelected]. +class ConfigurationCard extends StatelessWidget { + /// Creates a new [ConfigurationCard]. + const ConfigurationCard._({ + required this.flavor, + required this.onSelected, + required this.question, + this.textEditingController, + this.focusNode, + super.key, + }); + + /// Only provided when the question is a [TextQuestion] to maintain focus if + /// the appearance of an on-screen keyboard causes the screen to dramatically + /// alter its layout. + final TextEditingController? textEditingController; + + /// Only provided when the question is a [TextQuestion] to maintain focus if + /// the appearance of an on-screen keyboard causes the screen to dramatically + /// alter its layout. + final FocusNode? focusNode; + + /// + // ignore: strict_raw_type + static ConfigurationCard forQuestion( + Question question, { + required void Function(Object?) onAnswer, + Flavor flavor = Flavor.wood, + TextEditingController? textEditingController, + FocusNode? focusNode, + }) { + switch (question) { + case MultipleChoiceQuestion(): + return ConfigurationCard.buttons( + flavor: flavor, + onSelected: onAnswer, + question: question, + ); + case ZeroToOneQuestion(): + return ConfigurationCard.slider( + flavor: flavor, + onSelected: onAnswer, + question: question, + ); + case NegativeOneToOneQuestion(): + return ConfigurationCard.valueShiftSlider( + flavor: flavor, + onSelected: onAnswer, + question: question, + ); + case TextQuestion(): + return ConfigurationCard.text( + flavor: flavor, + onSelected: onAnswer, + question: question, + textEditingController: textEditingController, + focusNode: focusNode, + ); + } + } + + /// Radio buttons-style controls. + static ConfigurationCard buttons({ + required Flavor flavor, + required void Function(String) onSelected, + required MultipleChoiceQuestion question, + }) { + return ConfigurationCard._( + flavor: flavor, + onSelected: onSelected, + question: question, + ); + } + + /// Slider-style controls. + static ConfigurationCard slider({ + required Flavor flavor, + required void Function(double) onSelected, + required ZeroToOneQuestion question, + }) { + return ConfigurationCard._( + flavor: flavor, + onSelected: onSelected, + question: question, + ); + } + + /// Slider-style controls with "no change" in the middle. + static ConfigurationCard valueShiftSlider({ + required Flavor flavor, + required void Function(double) onSelected, + required NegativeOneToOneQuestion question, + }) { + return ConfigurationCard._( + flavor: flavor, + onSelected: onSelected, + question: question, + ); + } + + /// Text-style controls. + static ConfigurationCard text({ + required Flavor flavor, + required void Function(String) onSelected, + required TextQuestion question, + TextEditingController? textEditingController, + FocusNode? focusNode, + }) { + return ConfigurationCard._( + flavor: flavor, + onSelected: onSelected, + question: question, + textEditingController: textEditingController, + focusNode: focusNode, + ); + } + + /// Determines the background color. + final Flavor flavor; + + /// Complete definition of the question which will help configure this coffee. + final Question question; + + /// Method by which surrounding code learns of user activity. + final void Function(T) onSelected; + + @override + Widget build(BuildContext context) { + return LayoutProvider.builder( + builder: (context, info) { + final edgeInsets = max( + info.constrainingDimension * 0.1, + // Must be an explicit double + // ignore: prefer_int_literals + 16.0, + ).clamp(8.0, 100.0); + + final availableWidth = info.width - (edgeInsets * 2); + final availableHeight = info.height - (edgeInsets * 2); + + // Save 10% padding between + final availableHeightForQuestionOrAnswer = availableHeight * 0.45; + + final double maxFontSize = info.constrainingDimension * 0.15; + final double minFontSize = max(10, info.constrainingDimension * 0.03); + + final h1Style = Theme.of(context).typography.h1; + final textScaler = + MediaQuery.maybeTextScalerOf(context) ?? TextScaler.noScaling; + + (bool, int) questionFits(double fontSize) { + final tp = TextPainter( + text: TextSpan( + text: question.body, + style: h1Style.copyWith(fontSize: fontSize), + ), + textDirection: Directionality.of(context), + textScaler: textScaler, + )..layout(maxWidth: availableWidth); + final lineMetrics = tp.computeLineMetrics(); + final fits = tp.height <= availableHeightForQuestionOrAnswer; + tp.dispose(); + return (fits, lineMetrics.length); + } + + int? maxLinesForQuestion; + + double bestFontSize = minFontSize; + final (fits, maxLines) = questionFits(maxFontSize); + if (fits) { + bestFontSize = maxFontSize; + maxLinesForQuestion = maxLines; + } else { + double low = minFontSize; + double high = maxFontSize; + while (high - low > 0.5) { + final mid = (low + high) / 2; + final (innerFits, innerMaxLines) = questionFits(mid); + if (innerFits) { + bestFontSize = mid; + maxLinesForQuestion = innerMaxLines; + low = mid; + } else { + high = mid; + } + } + } + + final content = Container( + decoration: BoxDecoration( + color: flavor.backgroundColor, + borderRadius: BorderRadius.circular(16), + ), + child: Padding( + padding: EdgeInsets.all(edgeInsets), + child: Stack( + children: [ + Positioned( + height: availableHeightForQuestionOrAnswer, + width: availableWidth, + top: 0, + left: 0, + child: ParagraphTextWidget( + question.body, + style: h1Style.copyWith(fontSize: bestFontSize), + maxLines: maxLinesForQuestion, + ), + ), + Positioned( + height: availableHeightForQuestionOrAnswer, + width: availableWidth, + bottom: 0, + left: 0, + child: Align( + alignment: .bottomLeft, + child: switch (question) { + MultipleChoiceQuestion( + :final acceptableAnswers, + :final selectedValue, + ) => + _ConfigurationRadioButtons( + availableSize: Size( + availableWidth, + availableHeightForQuestionOrAnswer, + ), + flavor: flavor, + options: acceptableAnswers, + onSelected: onSelected as void Function(String), + selectedOption: selectedValue, + ), + ZeroToOneQuestion( + :final minValueLabel, + :final maxValueLabel, + :final selectedValue, + ) => + _ConfigurationSlider( + minValueLabel: minValueLabel, + maxValueLabel: maxValueLabel, + selectedValue: selectedValue, + onSelected: onSelected as void Function(double), + sliderType: .absoluteValue, + ), + NegativeOneToOneQuestion( + :final minValueLabel, + :final maxValueLabel, + :final selectedValue, + ) => + _ConfigurationSlider( + minValueLabel: minValueLabel, + maxValueLabel: maxValueLabel, + selectedValue: selectedValue, + onSelected: onSelected as void Function(double), + sliderType: .valueShift, + ), + TextQuestion( + :final answer, + :final helpText, + ) => + _ConfigurationTextInput( + helpText: helpText, + onSelected: onSelected as void Function(String), + selectedValue: answer, + controller: textEditingController, + focusNode: focusNode, + ), + }, + ), + ), + ], + ), + ), + ); + return SizedBox.fromSize(size: info.size, child: content); + }, + ); + } +} + +class _ConfigurationRadioButtons extends StatelessWidget { + const _ConfigurationRadioButtons({ + required this.availableSize, + required this.options, + required this.onSelected, + required this.selectedOption, + required this.flavor, + }); + + final Size availableSize; + final List? options; + + final void Function(String) onSelected; + + final String? selectedOption; + + final Flavor flavor; + + static const double rowSpacing = 8; + static const double columnSpacing = 8; + + @override + Widget build(BuildContext context) { + if (options == null || options!.isEmpty) { + return const SizedBox.shrink(); + } + + final isTight = availableSize.height < 112; + + return DynamicButtonLayout( + availableSize: availableSize, + options: options!, + isTight: isTight, + rowSpacing: rowSpacing, + columnSpacing: columnSpacing, + builder: (context, uiScale) { + return Wrap( + runSpacing: rowSpacing * uiScale, + spacing: columnSpacing * uiScale, + children: + options! // + .map( + (option) => GenLatteConfigurationButton( + onPressed: () { + onSelected(option); + }, + label: option, + isSelected: option == selectedOption, + isTight: isTight, + uiScale: uiScale, + ), + ) + .toList(), + ); + }, + ); + } +} + +/// Determines the type of slider to show. +enum _SliderType { + /// Represents a 0-to-1 value scale. + absoluteValue, + + /// Represents a -1-to-1 value scale, with 0 representing "no change". + valueShift, +} + +class _ConfigurationSlider extends StatelessWidget { + const _ConfigurationSlider({ + required this.sliderType, + required this.minValueLabel, + required this.maxValueLabel, + required this.selectedValue, + required this.onSelected, + }); + + final _SliderType sliderType; + + final String minValueLabel; + + final String maxValueLabel; + + final double? selectedValue; + + final void Function(double) onSelected; + + static const double _sliderHeight = 48; + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + final isTight = constraints.maxWidth < 250; + final minLabel = isTight + ? minValueLabel.split(' ').first + : minValueLabel; + final maxLabel = isTight + ? maxValueLabel.split(' ').first + : maxValueLabel; + + return WrappedText( + style: (context, theme) => switch (constraints.maxWidth) { + < 100 => theme.typography.large, + < 400 => theme.typography.small, + _ => theme.typography.base, + }, + child: Column( + mainAxisAlignment: .end, + children: [ + Row( + mainAxisAlignment: .spaceBetween, + children: [ + if (sliderType == .valueShift) ...[ + Text(minLabel), + if (!isTight) ...[ + const Text('No change'), + ], + Text(maxLabel, textAlign: .end), + ], + if (sliderType == .absoluteValue) ...[ + Text(minLabel), + Text(maxLabel, textAlign: .end), + ], + ], + ), + const SizedBox(height: 8), + Container( + height: _sliderHeight, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(8), + ), + child: LayoutBuilder( + builder: (context, constraints) { + return _SliderTrack( + constraints: constraints, + onSelected: onSelected, + sliderType: sliderType, + startingValue: selectedValue, + ); + }, + ), + ), + ], + ), + ); + }, + ); + } +} + +class _SliderTrack extends StatefulWidget { + const _SliderTrack({ + required this.constraints, + required this.sliderType, + required this.onSelected, + required this.startingValue, + }); + + final BoxConstraints constraints; + final _SliderType sliderType; + final void Function(double) onSelected; + final double? startingValue; + + @override + State<_SliderTrack> createState() => _SliderTrackState(); +} + +class _SliderTrackState extends State<_SliderTrack> { + /// Coordinates of the start of the user's drag. A null value here means no + /// drag is underway. + double? _dragStart; + + /// Coordinates of the current position of the user's drag. A null value here + /// means no drag is underway. + double? _dragCurrent; + + /// Computed delta to the starting knob position, stored between -1 and 1. + double _knobPositionDelta = 0; + + /// Absolute value of knob position. + double get _knobPositionAbsolute => + ((widget.constraints.maxWidth / 2) - _halfKnobWidth) * _knobPositionDelta; + + /// Absolute buffer space on either side of the track to prevent the knob from + /// going out of bounds. + double get _halfKnobWidth => widget.constraints.maxHeight / 2; + + /// The maximum number of pixels the center of the knob can travel in either + /// direction. + double get _distanceFromCenterToEdge => + widget.constraints.maxWidth / 2 - _halfKnobWidth; + + @override + void initState() { + super.initState(); + final startingValue = widget.startingValue; + if (startingValue != null) { + _knobPositionDelta = widget.sliderType == .absoluteValue + ? (startingValue * 2) - 1 + : startingValue; + } + } + + @override + void didUpdateWidget(covariant _SliderTrack oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.startingValue != oldWidget.startingValue && + widget.startingValue != null) { + _knobPositionDelta = widget.sliderType == .absoluteValue + ? (widget.startingValue! * 2) - 1 + : widget.startingValue!; + } + } + + void _onDragStart(DragStartDetails details) { + _dragCurrent = details.localPosition.dx; + + if (_knobPositionDelta != 0) { + _dragStart = + details.localPosition.dx - + (_knobPositionDelta * _distanceFromCenterToEdge); + } else { + _dragStart = details.localPosition.dx; + } + } + + void _onDragUpdate(DragUpdateDetails details) { + setState(() { + _dragCurrent = details.localPosition.dx; + + _knobPositionDelta = + ((_dragCurrent! - _dragStart!) / _distanceFromCenterToEdge) // + .clamp(-1, 1); + }); + } + + void _onDragEnd(DragEndDetails details) { + // _knobPositionDelta is a float between -1 and 1, which means it is already + // in the correct range for a [.valueShift] slider; but must be normalized + // to 0-1 for [.absoluteValue] sliders. To do this, we add 1, shifting the + // range from 0 to 2, then divide by 2 to make it 0 to 1. + final normalized = widget.sliderType == .absoluteValue + ? (_knobPositionDelta + 1) / 2 + : _knobPositionDelta; + widget.onSelected(normalized); + setState(() { + _dragStart = null; + _dragCurrent = null; + }); + } + + /// Where to place the left side of the slider such that its center aligns + /// with the center of the track. + double get _startingKnobPosition => + (widget.constraints.maxWidth / 2) - (widget.constraints.maxHeight / 2); + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + // Grey line acting as the track for the slider + Positioned( + left: _halfKnobWidth, + width: widget.constraints.maxWidth - (_halfKnobWidth * 2), + top: widget.constraints.maxHeight * 0.5, + height: 1, + child: Container( + decoration: const BoxDecoration( + color: Color(0xFFA2A2A2), + ), + ), + ), + // Blue knob + Positioned( + left: _startingKnobPosition + _knobPositionAbsolute, + width: widget.constraints.maxHeight, + top: 0, + // This measurement creates a rounded corner square + height: widget.constraints.maxHeight, + child: GestureDetector( + onHorizontalDragStart: _onDragStart, + onHorizontalDragUpdate: _onDragUpdate, + onHorizontalDragEnd: _onDragEnd, + child: Container( + decoration: BoxDecoration( + color: AppColors.googleBlue, + borderRadius: BorderRadius.circular(8), + ), + ), + ), + ), + ], + ); + } +} + +class _ConfigurationTextInput extends StatefulWidget { + const _ConfigurationTextInput({ + required this.helpText, + required this.onSelected, + required this.selectedValue, + this.controller, + this.focusNode, + }); + + final String helpText; + final void Function(String) onSelected; + final String? selectedValue; + final TextEditingController? controller; + final FocusNode? focusNode; + + @override + State<_ConfigurationTextInput> createState() => + _ConfigurationTextInputState(); +} + +class _ConfigurationTextInputState extends State<_ConfigurationTextInput> { + late TextEditingController _controller; + + @override + void initState() { + super.initState(); + _controller = + widget.controller ?? TextEditingController(text: widget.selectedValue); + } + + @override + void dispose() { + if (widget.controller == null) { + _controller.dispose(); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + // If the card is too short, then larger text sizes cause the input + // to overflow. + final double floor = constraints.maxHeight > 40 ? 0.9 : 0.5; + final double inputScalar = max(floor, constraints.maxWidth / 500); + + final textStyle = Theme.of(context).typography.large.copyWith( + fontSize: Theme.of(context).typography.large.fontSize! * inputScalar, + ); + return TextField( + decoration: BoxDecoration( + border: const Border(), // defaults to BorderStyle.none + borderRadius: BorderRadius.all( + Radius.circular(8 * inputScalar), + ), + color: Theme.of(context).colorScheme.input, + ), + controller: _controller, + focusNode: widget.focusNode, + onChanged: widget.onSelected, + enableInteractiveSelection: false, + placeholder: Text(widget.helpText, style: textStyle), + padding: EdgeInsets.symmetric( + horizontal: 16 * inputScalar, + vertical: 12 * inputScalar, + ), + style: textStyle, + ); + }, + ); + } +} diff --git a/genlatte/genlatte-ui/lib/src/widgets/design_scalar.dart b/genlatte/genlatte-ui/lib/src/widgets/design_scalar.dart new file mode 100644 index 0000000..1a2a737 --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/widgets/design_scalar.dart @@ -0,0 +1,76 @@ +// Copyright 2014 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. + +// /// @docImport 'aspect_ratio_scalar.dart'; +// library; + +// import 'dart:math' as math; +// import 'package:flutter/widgets.dart'; + +// /// A widget that provides a [DesignScalar] widget to its descendants. +// class ResponsiveDesignRoot extends StatelessWidget { +// /// Instantiates a new [ResponsiveDesignRoot]. +// const ResponsiveDesignRoot({ +// required this.designSize, +// required this.child, +// super.key, +// }); + +// /// Size of the original design, probably a tidy value like 1920x1080. +// final Size designSize; + +// /// The widget to render. +// final Widget child; + +// @override +// Widget build(BuildContext context) { +// // Get the total available screen size +// final screenSize = MediaQuery.sizeOf(context); + +// // Calculate the scale factor (using 'contain' logic to prevent clipping) +// final widthScale = screenSize.width / designSize.width; +// final heightScale = screenSize.height / designSize.height; +// final scaleFactor = math.min(widthScale, heightScale); + +// // Provide the calculated scale factor to the rest of the tree +// return DesignScalar( +// scaleFactor: scaleFactor, +// child: child, +// ); +// } +// } + +// /// Tells descendant widgets how much to scale design system values by. +// /// This is useful for global rules like padding between elements. To +// /// responsively scale the internals of a complicated widget, use a +// /// [AspectRatioScalar]. +// class DesignScalar extends InheritedWidget { +// /// Instantiates a new [DesignScalar]. +// const DesignScalar({ +// required this.scaleFactor, +// required super.child, +// super.key, +// }); + +// /// The lesser of two ratios (height and width) between the design system's +// /// dimensions and the available space. +// final double scaleFactor; + +// /// Allows descendant widgets to easily grab the scale factor. +// static double of(BuildContext context) { +// final DesignScalar? result = context +// .dependOnInheritedWidgetOfExactType(); +// assert( +// result != null, +// 'No DesignScalar found in context. ' +// 'Ensure you wrapped your app/screen in a DesignScalarRoot.', +// ); +// return result!.scaleFactor; +// } + +// @override +// bool updateShouldNotify(DesignScalar oldWidget) { +// return scaleFactor != oldWidget.scaleFactor; +// } +// } diff --git a/genlatte/genlatte-ui/lib/src/widgets/dynamic_button_layout.dart b/genlatte/genlatte-ui/lib/src/widgets/dynamic_button_layout.dart new file mode 100644 index 0000000..a196d74 --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/widgets/dynamic_button_layout.dart @@ -0,0 +1,182 @@ +// Copyright 2014 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:math'; + +import 'package:flutter/widgets.dart'; +import 'package:genlatte/src/widgets/buttons.dart'; + +/// Builder function that receives the calculated scaling factor for the UI. +typedef DynamicButtonLayoutBuilder = + Widget Function( + BuildContext context, + double uiScale, + ); + +/// A widget that analytically figures out the optimal UI scaling factor +/// necessary to fit a sequence of GenLatteConfigurationButton widgets into +/// the provided horizontal and vertical space. +class DynamicButtonLayout extends StatefulWidget { + /// Creates a new DynamicButtonLayout. + const DynamicButtonLayout({ + required this.availableSize, + required this.options, + required this.isTight, + required this.builder, + required this.rowSpacing, + required this.columnSpacing, + this.minScale = 0.1, + this.maxScale = 2.0, + super.key, + }); + + /// The size the layout must fit within. + final Size availableSize; + + /// The labels that will be placed in the buttons. + final List options; + + /// Whether the buttons should be rendered with tight padding. + final bool isTight; + + /// Render the final layout with the calculated `uiScale`. + final DynamicButtonLayoutBuilder builder; + + /// Vertical spacing between wrapped rows. + final double rowSpacing; + + /// Horizontal spacing between buttons on the same row. + final double columnSpacing; + + /// The minimum scale factor to softly bottom out at. + final double minScale; + + /// The maximum scale factor to search up to. + final double maxScale; + + @override + State createState() => _DynamicButtonLayoutState(); +} + +class _DynamicButtonLayoutState extends State { + // Cache to store the computed optimal UI scale for a given set of parameters. + static final Map _scaleCache = {}; + + @override + Widget build(BuildContext context) { + if (widget.options.isEmpty) { + return widget.builder(context, 1); + } + + final defaultStyle = DefaultTextStyle.of(context).style; + final textScaler = + MediaQuery.maybeTextScalerOf(context) ?? TextScaler.noScaling; + + // Generate a hash representing all relevant layout inputs. + final hashKey = Object.hash( + widget.availableSize, + Object.hashAll(widget.options), + widget.isTight, + widget.rowSpacing, + widget.columnSpacing, + widget.minScale, + widget.maxScale, + defaultStyle, + textScaler, + ); + + if (_scaleCache.containsKey(hashKey)) { + return widget.builder(context, _scaleCache[hashKey]!); + } + + double low = widget.minScale; + double high = widget.maxScale; + double bestScale = low; + + // Binary search to find the highest scale that fits. + if (_fits(high, defaultStyle, textScaler)) { + bestScale = high; + } else { + while (high - low > 0.01) { + final mid = (low + high) / 2; + if (_fits(mid, defaultStyle, textScaler)) { + bestScale = mid; + low = mid; + } else { + high = mid; + } + } + } + + _scaleCache[hashKey] = bestScale; + return widget.builder(context, bestScale); + } + + bool _fits( + double scale, + TextStyle baseStyle, + TextScaler textScaler, + ) { + final paddingX = + (widget.isTight + ? GenLatteConfigurationButton.tightPadding.horizontal + : GenLatteConfigurationButton.normalPadding.horizontal) * + scale; + final paddingY = + (widget.isTight + ? GenLatteConfigurationButton.tightPadding.vertical + : GenLatteConfigurationButton.normalPadding.vertical) * + scale; + + // We add an extra 4 pixels conservatively to account for shadcn + // internal border/focus ring spacing that might be unaccounted for. + const borderSlopX = 6.0; + const borderSlopY = 6.0; + + double totalHeight = 0; + double currentRowWidth = 0; + double rowHeight = 0; + + for (int i = 0; i < widget.options.length; i++) { + // Calculate exactly for this scale using proper text scaler and style + final tp = TextPainter( + text: TextSpan( + text: widget.options[i], + style: baseStyle.copyWith(fontSize: 14 * scale), + ), + textDirection: TextDirection.ltr, + textScaler: textScaler, + )..layout(); + + // We conservatively buffer the size by 1.05 and add the slop + final buttonWidth = tp.width * 1.05 + paddingX + borderSlopX; + final buttonHeight = tp.height * 1.05 + paddingY + borderSlopY; + + if (currentRowWidth + buttonWidth > widget.availableSize.width) { + // Need to wrap to the next row (if we already have items) + if (currentRowWidth > 0) { + totalHeight += rowHeight + (widget.rowSpacing * scale); + } + currentRowWidth = buttonWidth + (widget.columnSpacing * scale); + rowHeight = buttonHeight; + + if (buttonWidth > widget.availableSize.width) { + // Even alone, this button is too wide to fit the width constraint! + return false; + } + } else { + // Fits perfectly on the current row + currentRowWidth += buttonWidth + (widget.columnSpacing * scale); + rowHeight = max(rowHeight, buttonHeight); + } + } + + // Add the height of the last row + if (rowHeight > 0) { + totalHeight += rowHeight; + } + + return totalHeight <= widget.availableSize.height; + } +} diff --git a/genlatte/genlatte-ui/lib/src/widgets/extensions.dart b/genlatte/genlatte-ui/lib/src/widgets/extensions.dart new file mode 100644 index 0000000..9522543 --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/widgets/extensions.dart @@ -0,0 +1,30 @@ +// Copyright 2014 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'; + +/// +extension DebuggableWidget on Widget { + /// + Widget border({Color? color}) { + return Container( + decoration: BoxDecoration( + border: Border.all( + color: + color ?? + const Color.from( + alpha: 1, + red: 1, + green: 0, + blue: 0, + ), + ), + ), + child: this, + ); + } + + /// + Widget withBorder({Color? color}) => border(color: color); +} diff --git a/genlatte/genlatte-ui/lib/src/widgets/footer.dart b/genlatte/genlatte-ui/lib/src/widgets/footer.dart new file mode 100644 index 0000000..910f028 --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/widgets/footer.dart @@ -0,0 +1,92 @@ +// Copyright 2014 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:math'; + +import 'package:genlatte/src/screens/app/app.dart'; +import 'package:genlatte/src/widgets/responsive_sized_box.dart'; +import 'package:genlatte/src/widgets/widgets.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +/// The 3-logo footer widget. +class Footer extends StatelessWidget { + /// Instantiates the footer widget. + const Footer({this.fontColor = AppColors.white, this.uiScale = 1.0}) + : super(key: const ValueKey('app-footer')); + + /// The color of the footer text. + final Color fontColor; + + /// The scale of the footer. + final double uiScale; + + /// Default size of all assets is about ~301 pixels. This size was derived + /// by first laying out the assets and measuring; not by picking a value and + /// squeezing the assets to fit. + static const double defaultWidth = 302; + + /// Default height of all assets is about ~24 pixels. This size was derived + /// by first laying out the assets and measuring; not by picking a value and + /// squeezing the assets to fit. + static const double defaultHeight = 24; + + static const double _defaultFontSize = 12; + + static const double _defaultLogoWidth = 32; + static const double _defaultSpacerWidth = 4; + + @override + Widget build(BuildContext context) { + return ResponsiveSizedBox.builder( + aspectRatioClamp: (null, 302 / 24, null), + maxSize: Size(defaultWidth * uiScale, defaultHeight * uiScale), + builder: (context, size) { + final heightScale = size.height / defaultHeight; + final widthScale = size.width / defaultWidth; + final double scale = min(heightScale, widthScale); + final double spacerWidth = _defaultSpacerWidth * widthScale; + final double fontSize = _defaultFontSize * scale; + final double logoWidth = _defaultLogoWidth * scale; + + // Create repeatable widgets + final spacer = SizedBox(width: spacerWidth); + final verticalBar = _VerticalBar(scale: scale); + final style = TextStyle(color: fontColor, fontSize: fontSize); + return Row( + children: [ + Image.asset('assets/gemini-logo.png', width: logoWidth), + spacer, + Text('Gemini', style: style), + verticalBar, + Image.asset('assets/firebase-logo.png', width: logoWidth), + spacer, + Text('Firebase', style: style), + verticalBar, + Image.asset('assets/flutter-logo.png', width: logoWidth), + spacer, + Text('Flutter', style: style), + ], + ); + }, + ); + } +} + +class _VerticalBar extends StatelessWidget { + const _VerticalBar({required this.scale}); + + final double scale; + + @override + Widget build(BuildContext context) { + return Padding( + padding: EdgeInsets.symmetric(horizontal: 12 * scale), + child: VerticalDivider( + width: 1 * scale, + thickness: 1 * scale, + color: const Color(0xFF838383), + ), + ); + } +} diff --git a/genlatte/genlatte-ui/lib/src/widgets/genlatte_scaffold.dart.dart b/genlatte/genlatte-ui/lib/src/widgets/genlatte_scaffold.dart.dart new file mode 100644 index 0000000..bdba06c --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/widgets/genlatte_scaffold.dart.dart @@ -0,0 +1,129 @@ +// Copyright 2014 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:genlatte/src/screens/app/app.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +/// Wraps a shadcn [Scaffold] to supply the dotted background behind all +/// customer-facing screens. +class GenLatteScaffold extends StatelessWidget { + /// Instantiates a [GenLatteScaffold] widget. + const GenLatteScaffold({ + required this.child, + this.headers = const [], + this.footers = const [], + super.key, + }); + + /// Header widgets passed straight to the shadcn [Scaffold]. + final List headers; + + /// Footer widgets passed straight to the shadcn [Scaffold]. + final List footers; + + /// The UI to sit atop the dotted background. + final Widget child; + + @override + Widget build(BuildContext context) { + return ColoredBox( + color: AppColors.almostBlack, + child: Stack( + fit: StackFit.expand, + children: [ + // 1. The Background Layer + LayoutBuilder( + builder: (context, constraints) { + return CustomPaint( + size: Size(constraints.maxWidth, constraints.maxHeight), + painter: _CircleTessellationPainter( + circleColor: AppColors.black, + ), + ); + }, + ), + // 2. The Content Layer + Scaffold( + backgroundColor: Colors.transparent, + headers: headers, + footers: footers, + child: child, + ), + ], + ), + ); + } +} + +class _CircleTessellationPainter extends CustomPainter { + _CircleTessellationPainter({required this.circleColor}); + final Color circleColor; + + /// Constants from requirements + static const double circleRadius = 100; + static const double circleDiameter = circleRadius * 2; + + @override + void paint(Canvas canvas, Size size) { + final paint = Paint() + ..color = circleColor + ..style = PaintingStyle.fill; + + final double centerX = size.width / 2; + final double centerY = size.height / 2; + + // Calculate how many columns/rows we need to cover the screen plus buffers + // We add 1 to the count to ensure circles clipped by the edge are still + // drawn. + final int horizontalCount = (centerX / circleDiameter).ceil() + 1; + final int verticalCount = (centerY / circleDiameter).ceil() + 1; + + // Helper function to draw a vertical column of circles at a specific X + void drawColumn(double xCenter) { + // Draw center circle + canvas.drawCircle(Offset(xCenter, centerY), circleRadius, paint); + + // Draw circles moving Up and Down from center + for (int i = 1; i <= verticalCount; i++) { + final offset = i * circleDiameter; + canvas + ..drawCircle( + Offset(xCenter, centerY - offset), + circleRadius, + paint, + ) + ..drawCircle( + Offset(xCenter, centerY + offset), + circleRadius, + paint, + ); + } + } + + // --- Draw Columns --- + + // The requirement states the vertical center is a junction of two columns. + // Innermost centers are 180px left and right of center. + + // Draw the Right side columns (starting at center + 180) + for (int i = 0; i <= horizontalCount; i++) { + // center + 180, center + 540, etc. + final x = centerX + circleRadius + (i * circleDiameter); + drawColumn(x); + } + + // Draw the Left side columns (starting at center - 180) + for (int i = 0; i <= horizontalCount; i++) { + // center - 180, center - 540, etc. + final x = centerX - circleRadius - (i * circleDiameter); + drawColumn(x); + } + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) { + // Since the circles are static, we never need to repaint unless rebuilt + return false; + } +} diff --git a/genlatte/genlatte-ui/lib/src/widgets/latte_image.dart b/genlatte/genlatte-ui/lib/src/widgets/latte_image.dart new file mode 100644 index 0000000..e38331a --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/widgets/latte_image.dart @@ -0,0 +1,75 @@ +// Copyright 2014 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/rendering.dart'; +import 'package:flutter/widgets.dart'; +import 'package:genlatte/src/widgets/stacked_images.dart'; + +/// Renders a latte image with the cup outline. +class LatteImageWidget extends StatelessWidget { + /// Creates a [LatteImageWidget] widget. + const LatteImageWidget({ + required this.imageUrl, + this.dimension, + this.topScaleRatio = 0.92, + this.thumbnailSize = false, + super.key, + }); + + /// Size of the widget. + final double? dimension; + + /// The latte image to show above the coffee cup image. + final String imageUrl; + + /// The scale ratio of the [imageUrl] image relative to the cup background. + final double topScaleRatio; + + /// Set this to `true` to convey that the widget will always be + /// thumbnail-sized and thus can use a low-resolution asset. + /// + /// Defaults to `false`. + final bool thumbnailSize; + + @override + Widget build(BuildContext context) { + final child = StackedImages( + bottom: Image.asset( + thumbnailSize + ? 'assets/latte-background-thumb.png' + : 'assets/latte-background.png', + ), + top: _MultiplyBlend( + child: ClipOval(child: Image.network(imageUrl, fit: .cover)), + ), + topScaleRatio: topScaleRatio, + ); + + if (dimension != null) { + return SizedBox.square(dimension: dimension, child: child); + } + return child; + } +} + +class _MultiplyBlend extends SingleChildRenderObjectWidget { + const _MultiplyBlend({super.child}); + + @override + RenderObject createRenderObject(BuildContext context) => _RenderMultiply(); +} + +class _RenderMultiply extends RenderProxyBox { + @override + void paint(PaintingContext context, Offset offset) { + if (child != null) { + context.canvas.saveLayer( + offset & size, + Paint()..blendMode = BlendMode.multiply, + ); + context.paintChild(child!, offset); + context.canvas.restore(); + } + } +} diff --git a/genlatte/genlatte-ui/lib/src/widgets/layout_provider.dart b/genlatte/genlatte-ui/lib/src/widgets/layout_provider.dart new file mode 100644 index 0000000..af6104c --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/widgets/layout_provider.dart @@ -0,0 +1,58 @@ +// Copyright 2014 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:genlatte/src/widgets/responsive_sized_box.dart'; +import 'package:provider/provider.dart'; + +/// A widget that provides [LayoutInformation] to its descendants. +class LayoutProvider extends StatelessWidget { + /// Child pattern for [LayoutProvider]. + factory LayoutProvider({ + required Widget child, + Key? key, + }) => LayoutProvider._( + key: key, + child: child, + ); + + /// Creates a new [LayoutProvider]. + const LayoutProvider._({ + this.builder, + this.child, + super.key, + }) : assert( + child != null || builder != null, + 'Provide either child or builder', + ); + + /// Builder pattern for [LayoutProvider]. + factory LayoutProvider.builder({ + required Widget Function(BuildContext, LayoutInformation) builder, + Key? key, + }) => LayoutProvider._( + key: key, + builder: builder, + ); + + /// The child widget. + final Widget? child; + + /// Builder function that receives the optimal size and returns the widget to + /// display. Provide this or [child]. + final Widget Function(BuildContext, LayoutInformation)? builder; + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + final info = LayoutInformation.fromBoxConstraints(constraints); + return Provider.value( + value: info, + child: child ?? builder!(context, info), + ); + }, + ); + } +} diff --git a/genlatte/genlatte-ui/lib/src/widgets/loading_dash.dart b/genlatte/genlatte-ui/lib/src/widgets/loading_dash.dart new file mode 100644 index 0000000..7e683d9 --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/widgets/loading_dash.dart @@ -0,0 +1,102 @@ +// Copyright 2014 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' + show TargetPlatform, defaultTargetPlatform, kIsWeb; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:video_player/video_player.dart'; +import 'package:web_browser_detect/web_browser_detect.dart'; + +/// Displays the loading animation of Dash imagining various Google logos as +/// latte art. +/// +/// Renders nothing on Safari web, which does not support transparency in +/// videos. +class LoadingDash extends StatelessWidget { + /// Instantiates a [LoadingDash]. + const LoadingDash({super.key}); + + @override + Widget build(BuildContext context) { + if (kIsWeb) { + final browser = Browser(); + if (browser.browserAgent == BrowserAgent.safari) { + return const SizedBox.shrink(); + } + } + return const _LoadingDashInner(); + } +} + +class _LoadingDashInner extends StatefulWidget { + const _LoadingDashInner(); + + @override + State<_LoadingDashInner> createState() => _LoadingDashState(); +} + +class _LoadingDashState extends State<_LoadingDashInner> { + late VideoPlayerController _controller; + + @override + void initState() { + super.initState(); + + // Use the asset constructor + _controller = + VideoPlayerController.asset( + kIsWeb + ? 'assets/videos/Dash_Loading_1080px.webm' + : 'assets/videos/Dash_Loading_1080px_H.265.mov', + ) + ..initialize() + .then((_) { + if (!mounted) return; + _controller.setLooping(true).ignore(); + _controller.play().ignore(); + // Ensure the first frame is shown after the video is + // initialized. + setState(() {}); + }) + .onError((error, stackTrace) { + debugPrint('Failed to initialize video player: $error'); + }) + .ignore(); + } + + @override + Widget build(BuildContext context) { + Widget widget = VideoPlayer(_controller); + + // Android isn't honoring transparency in either webm or h265, but this + // color filter drops the alpha channel to 0 for black pixels, which is the + // same for our purposes. + final isAndroid = defaultTargetPlatform == TargetPlatform.android; + + if (isAndroid) { + widget = ColorFiltered( + colorFilter: const ColorFilter.matrix([ + 1, 0, 0, 0, 0, // + 0, 1, 0, 0, 0, // + 0, 0, 1, 0, 0, // + 1, 1, 1, 0, -1, // + ]), + child: widget, + ); + } + + return Center( + child: AspectRatio( + aspectRatio: _controller.value.aspectRatio, + child: widget, + ), + ); + } + + @override + void dispose() { + _controller.dispose().ignore(); + super.dispose(); + } +} diff --git a/genlatte/genlatte-ui/lib/src/widgets/machine_selection.dart b/genlatte/genlatte-ui/lib/src/widgets/machine_selection.dart new file mode 100644 index 0000000..b8080a4 --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/widgets/machine_selection.dart @@ -0,0 +1,190 @@ +// Copyright 2014 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:genlatte/src/widgets/widgets.dart'; +import 'package:genlatte_data/models.dart'; + +/// A reusable widget that allows users to select a [Machine] from a list. +class MachineSelection extends StatefulWidget { + /// Creates a new [MachineSelection] widget. + const MachineSelection({ + required this.machines, + required this.onSubmitted, + this.initialSelection, + super.key, + }); + + /// The list of available machines to choose from. + final List machines; + + /// The machine that should be selected by default. + final Machine? initialSelection; + + /// Callback fired when the user selects a machine and clicks "Submit". + final void Function(Machine) onSubmitted; + + @override + State createState() => _MachineSelectionState(); +} + +class _MachineSelectionState extends State { + Machine? _selectedMachine; + + @override + void initState() { + super.initState(); + _selectedMachine = widget.initialSelection; + } + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: .min, + crossAxisAlignment: .start, + children: [ + Flexible( + child: ListView.separated( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: widget.machines.length, + separatorBuilder: (context, index) => const SizedBox(height: 12), + itemBuilder: (context, index) { + final machine = widget.machines[index]; + final isSelected = _selectedMachine?.id == machine.id; + + return _MachineSelectionTile( + machine: machine, + isSelected: isSelected, + onTap: () => setState(() => _selectedMachine = machine), + ); + }, + ), + ), + const SizedBox(height: 24), + if (_selectedMachine != null) + Flexible( + child: GenLatteOutlinedButton.dark( + label: 'Submit Selection', + onPressed: () => widget.onSubmitted(_selectedMachine!), + ), + ), + ], + ); + } +} + +class _MachineSelectionTile extends StatefulWidget { + const _MachineSelectionTile({ + required this.machine, + required this.isSelected, + required this.onTap, + }); + + final Machine machine; + final bool isSelected; + final VoidCallback onTap; + + @override + State<_MachineSelectionTile> createState() => _MachineSelectionTileState(); +} + +class _MachineSelectionTileState extends State<_MachineSelectionTile> { + bool _isHovered = false; + + @override + Widget build(BuildContext context) { + final isSelected = widget.isSelected; + + return MouseRegion( + onEnter: (_) => setState(() => _isHovered = true), + onExit: (_) => setState(() => _isHovered = false), + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: widget.onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), + decoration: BoxDecoration( + color: isSelected + ? const Color(0xFF18181B) // Zinc 900 + : (_isHovered + ? const Color(0xFFF4F4F5) + : Colors.white), // Zinc 100 + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: isSelected + ? const Color(0xFF18181B) + : const Color(0xFFE4E4E7), // Zinc 200 + width: 2, + ), + boxShadow: [ + if (isSelected || _isHovered) + BoxShadow( + color: Colors.black.withValues(alpha: 0.05), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], + ), + child: Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: isSelected + ? const Color(0xFF27272A) // Zinc 800 + : const Color(0xFFF4F4F5), // Zinc 100 + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + Icons.coffee_maker_outlined, + color: isSelected + ? Colors.white + : const Color(0xFF71717A), // Zinc 500 + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.machine.name, + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: isSelected + ? Colors.white + : const Color(0xFF18181B), + ), + ), + const SizedBox(height: 2), + Text( + widget.machine.isBlackAndWhite + ? 'Black & White 🔲' + : 'Full Color 🎨', + style: TextStyle( + fontSize: 14, + color: isSelected + ? Colors.white.withValues(alpha: 0.7) + : const Color(0xFF71717A), + ), + ), + ], + ), + ), + if (isSelected) + const Icon( + Icons.check_circle, + color: Colors.white, + ), + ], + ), + ), + ), + ); + } +} diff --git a/genlatte/genlatte-ui/lib/src/widgets/overflow_marquee.dart b/genlatte/genlatte-ui/lib/src/widgets/overflow_marquee.dart new file mode 100644 index 0000000..e1ff64f --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/widgets/overflow_marquee.dart @@ -0,0 +1,162 @@ +// Copyright 2014 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:shadcn_flutter/shadcn_flutter.dart'; + +/// A widget that takes a string and, if it overflows its horizontal +/// constraints, smoothly scrolls it in place to reveal the hidden text. +class OverflowMarquee extends StatefulWidget { + /// Creates an [OverflowMarquee]. + const OverflowMarquee({ + required this.text, + super.key, + this.style, + this.scrollVelocity = 35.0, + this.pauseDuration = const Duration(seconds: 5), + this.loop = false, + }); + + /// The text to display. + final String text; + + /// The style of the text. + final TextStyle? style; + + /// The scrolling velocity in logical pixels per second. + final double scrollVelocity; + + /// The pause duration at the beginning and the end of the scroll. + final Duration pauseDuration; + + /// Whether to instantly jump to the start after reaching the end (true), + /// or to scroll back to the start (false). Default is false (ping-pong). + final bool loop; + + @override + State createState() => _OverflowMarqueeState(); +} + +class _OverflowMarqueeState extends State { + late ScrollController _scrollController; + bool _isAnimating = false; + int _runToken = 0; + + @override + void initState() { + super.initState(); + _scrollController = ScrollController(); + WidgetsBinding.instance.addPostFrameCallback( + (_) => unawaited(_checkAndStart()), + ); + } + + @override + void didUpdateWidget(OverflowMarquee oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.text != oldWidget.text || widget.style != oldWidget.style) { + unawaited(_checkAndStart()); + } + } + + @override + void dispose() { + _runToken++; + _scrollController.dispose(); + super.dispose(); + } + + Future _checkAndStart() async { + final currentToken = ++_runToken; + if (!_scrollController.hasClients) return; + + // Reset position to 0 if the text or constraints updated. + _scrollController.jumpTo(0); + + // Give the layout a frame to calculate the new maxScrollExtent. + await Future.delayed(Duration.zero); + + if (!mounted || + _runToken != currentToken || + !_scrollController.hasClients) { + return; + } + + final maxExtent = _scrollController.position.maxScrollExtent; + if (maxExtent > 0) { + unawaited(_runLoop(currentToken)); + } + } + + Future _runLoop(int token) async { + if (_isAnimating) return; + _isAnimating = true; + + try { + while (mounted && _runToken == token && _scrollController.hasClients) { + // Pause at the start + await Future.delayed(widget.pauseDuration); + if (!mounted || _runToken != token || !_scrollController.hasClients) { + break; + } + + final maxExtent = _scrollController.position.maxScrollExtent; + if (maxExtent <= 0) break; // Stop if text no longer overflows + + // Animate to the end + final duration = Duration( + milliseconds: (maxExtent / widget.scrollVelocity * 1000).toInt(), + ); + + await _scrollController.animateTo( + maxExtent, + duration: duration, + curve: Curves.linear, + ); + + if (!mounted || _runToken != token || !_scrollController.hasClients) { + break; + } + + // Pause at the end + await Future.delayed(widget.pauseDuration); + if (!mounted || _runToken != token || !_scrollController.hasClients) { + break; + } + + // Return to start + if (widget.loop) { + _scrollController.jumpTo(0); + } else { + await _scrollController.animateTo( + 0, + duration: const Duration(milliseconds: 500), + curve: Curves.easeInOut, + ); + } + } + } finally { + if (_runToken == token) { + _isAnimating = false; + } + } + } + + @override + Widget build(BuildContext context) { + // SingleChildScrollView automatically respects the Directionality of the + // context, scrolling properly based on text direction (LTR vs RTL). + return SingleChildScrollView( + controller: _scrollController, + scrollDirection: Axis.horizontal, + physics: const NeverScrollableScrollPhysics(), + child: Text( + widget.text, + style: widget.style, + maxLines: 1, + ), + ); + } +} diff --git a/genlatte/genlatte-ui/lib/src/widgets/responsive_sized_box.dart b/genlatte/genlatte-ui/lib/src/widgets/responsive_sized_box.dart new file mode 100644 index 0000000..202127a --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/widgets/responsive_sized_box.dart @@ -0,0 +1,374 @@ +// Copyright 2014 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:math'; + +import 'package:flutter/widgets.dart'; + +/// Responsive layout information about the available space. Primarily derived +/// from [BoxConstraints]. +class LayoutInformation { + /// Instantiates a [LayoutInformation]. + const LayoutInformation._(this.constraints); + + /// Builds a [LayoutInformation] from the given [BoxConstraints]. + factory LayoutInformation.fromBoxConstraints(BoxConstraints constraints) => + LayoutInformation._(constraints); + + /// The constraints of the available space. + final BoxConstraints constraints; + + /// Horizontal pixels of the available space. + double get width => constraints.maxWidth; + + /// Vertical pixels of the available space. + double get height => constraints.maxHeight; + + /// The smaller of the width and height. + double get constrainingDimension => min(width, height); + + /// The larger of the width and height. + double get unconstrainingDimension => max(width, height); + + /// The size of the available space. + Size get size => Size(width, height); + + /// The aspect ratio of the available space. + double get aspectRatio => width / height; + + /// Returns the most constraining scale for a given ideal size. + double constrainedScale(Size idealSize) => min( + width / idealSize.width, + height / idealSize.height, + ); + + /// Returns the least constraining scale for a given ideal size. + double unconstrainedScale(Size idealSize) => max( + width / idealSize.width, + height / idealSize.height, + ); + + /// Aspect ratio category of this layout. + LayoutOrientation get orientation => width == height + ? .square + : width > height + ? .landscape + : .portrait; +} + +/// The orientation of a Cartesian object. +enum LayoutOrientation { + /// Taller than wide. + portrait(), + + /// Wider than tall. + landscape, + + /// Same height and width. + square; + + /// True if this orientation is landscape. + bool get isLandscape => this == .landscape; + + /// True if this orientation is portrait. + bool get isPortrait => this == .portrait; + + /// True if this orientation is square. + bool get isSquare => this == .square; + + /// Returns the desired Flex [Axis] for this orientation. e.g., if we are in a + /// landscape orientation, we want to lay out our children horizontally. + Axis get axis => this == .landscape ? .horizontal : .vertical; +} + +/// {@template AcceptableAspectRatios} +/// Range of allowed aspect ratios. +/// +/// $1 is optional and represents the lowest (tallest) acceptable aspect ratio. +/// $2 is the ideal aspect ratio which will be selected if possible. +/// $3 is optional and represents the highest (widest) acceptable aspect ratio. +/// {@endtemplate} +typedef AcceptableAspectRatios = (double?, double?, double?); + +/// A widget that sizes itself based on the available space and the +/// range of acceptable aspect ratios. +class ResponsiveSizedBox extends StatelessWidget { + /// Instantiates a [ResponsiveSizedBox]. + factory ResponsiveSizedBox({ + required AcceptableAspectRatios aspectRatioClamp, + required Widget child, + Alignment? alignment = .center, + Size? maxSize, + Key? key, + }) => ResponsiveSizedBox._( + alignment: alignment, + aspectRatioClamp: aspectRatioClamp, + maxSize: maxSize, + key: key, + child: child, + ); + + /// Builder pattern for [ResponsiveSizedBox]. + factory ResponsiveSizedBox.builder({ + required AcceptableAspectRatios aspectRatioClamp, + required Widget Function(BuildContext, Size) builder, + Alignment? alignment = .center, + Size? maxSize, + Key? key, + }) => ResponsiveSizedBox._( + alignment: alignment, + aspectRatioClamp: aspectRatioClamp, + builder: builder, + maxSize: maxSize, + key: key, + ); + + /// Generative constructor for [ResponsiveSizedBox]. + const ResponsiveSizedBox._({ + required this.aspectRatioClamp, + required this.alignment, + this.builder, + this.maxSize, + this.child, + super.key, + }) : assert( + child != null || builder != null, + 'Provide either child or builder', + ); + + /// {@macro AcceptableAspectRatios} + final AcceptableAspectRatios aspectRatioClamp; + + /// The alignment of the child within the available space. + final Alignment? alignment; + + /// Optional maximum size for the widget. + final Size? maxSize; + + /// Descendant widget to scale responsively. Provide this or [builder]. + final Widget? child; + + /// Builder function that receives the optimal size and returns the widget to + /// display. Provide this or [child]. + final Widget Function(BuildContext, Size)? builder; + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + final scalar = AspectRatioScalar( + aspectRatioClamp, + constraints: constraints, + maxSize: maxSize, + ); + + Widget result = child ?? builder!(context, scalar.optimalSize); + + result = SizedBox.fromSize( + size: scalar.optimalSize, + child: result, + ); + + if (alignment != null) { + result = Align( + alignment: alignment!, + child: result, + ); + } + return result; + }, + ); + } +} + +/// Provides sizing information based on available dimensions and aspect ratios. +/// +/// Usage: +/// ```dart +/// +/// // Tallest allowed +/// double minAcceptableAspectRatio = 0.5; +/// // Ideal +/// double idealAspectRatio = 0.75; +/// // Widest allowed +/// double maxAcceptableAspectRatio = 1; +/// +/// final scalar = AspectRatioScalar( +/// (minAcceptableAspectRatio, idealAspectRatio, maxAcceptableAspectRatio), +/// constraints: constraints, +/// maxSize: const Size(defaultWidth, defaultHeight), +/// ); +/// +/// final scaledSize = scalar.optimalSize; +/// final scaledHeight = scalar.scaleHeight(100); +/// final scaledWidth = scalar.scaleWidth(100); +/// ``` +class AspectRatioScalar { + /// Instantiates an [AspectRatioScalar]. + AspectRatioScalar( + this.aspectRatioClamp, { + required this.constraints, + this.maxSize, + }) : assert(() { + if (aspectRatioClamp.$1 != null && + aspectRatioClamp.$2 != null && + aspectRatioClamp.$1! >= aspectRatioClamp.$2!) { + throw AssertionError( + 'aspectRatioClamp min must be less than ideal. ' + '${aspectRatioClamp.$1} >= ${aspectRatioClamp.$2}', + ); + } + if (aspectRatioClamp.$1 != null && + aspectRatioClamp.$3 != null && + aspectRatioClamp.$1! >= aspectRatioClamp.$3!) { + throw AssertionError( + 'aspectRatioClamp min must be less than max. ' + '${aspectRatioClamp.$1} >= ${aspectRatioClamp.$3}', + ); + } + if (aspectRatioClamp.$2 != null && + aspectRatioClamp.$3 != null && + aspectRatioClamp.$2! >= aspectRatioClamp.$3!) { + throw AssertionError( + 'aspectRatioClamp ideal must be less than max. ' + '${aspectRatioClamp.$2} >= ${aspectRatioClamp.$3}', + ); + } + return true; + }(), 'Invalid aspectRatioClamp'); + + /// {@macro AcceptableAspectRatios} + final AcceptableAspectRatios aspectRatioClamp; + + /// The actual constraints of the widget, probably provided by a + /// LayoutBuilder. + final BoxConstraints constraints; + + /// The maximum size of the widget. + final Size? maxSize; + + /// Runtime aspect ratio of the actually available space. + double get constraintsAspectRatio => + constraints.maxWidth / constraints.maxHeight; + + /// The given aspect ratio clamped to the allowed range. + double get optimalAspectRatio => + constraintsAspectRatio.clamp(minAspectRatio, maxAspectRatio); + + Size? _optimalSize; + + /// Returns the optimal [Size] for the desired aspect ratio and runtime + /// [BoxConstraints]. + Size get optimalSize { + if (_optimalSize == null) { + if (optimalAspectRatio > constraintsAspectRatio) { + // Optimal size is wider than available space; width is the constraint + _optimalSize = Size( + constraints.maxWidth, + constraints.maxWidth / optimalAspectRatio, + ); + } else { + // Available space is wider than the optimal aspect ratio; height is + // the constraint + _optimalSize = Size( + constraints.maxHeight * optimalAspectRatio, + constraints.maxHeight, + ); + } + + if (maxSize != null) { + // Find the scale required to fit inside maxSize without changing the + // aspect ratio. We only scale down (scale < 1.0). + final scaleX = maxSize!.width / _optimalSize!.width; + final scaleY = maxSize!.height / _optimalSize!.height; + final double minScale = min(1, min(scaleX, scaleY)); + + _optimalSize = _optimalSize! * minScale; + } + } + assert(() { + if (!_lte(_optimalSize!.height, constraints.maxHeight)) { + throw AssertionError( + 'Calculated scaledSize height exceeded constraints. ' + '${_optimalSize!.height} > ${constraints.maxHeight}', + ); + } + if (!_lte(_optimalSize!.width, constraints.maxWidth)) { + throw AssertionError( + 'Calculated scaledSize width exceeded constraints. ' + ' ${_optimalSize!.width} > ${constraints.maxWidth}', + ); + } + if (maxSize != null) { + if (!_lte(_optimalSize!.height, maxSize!.height)) { + throw AssertionError( + 'Calculated scaledSize height exceeded maxSize. ' + '${_optimalSize!.height} > ${maxSize!.height}', + ); + } + if (!_lte(_optimalSize!.width, maxSize!.width)) { + throw AssertionError( + 'Calculated scaledSize width exceeded maxSize. ' + '${_optimalSize!.width} > ${maxSize!.width}', + ); + } + } + return true; + }(), 'Invalid optimalSize'); + return _optimalSize!; + } + + /// Scales a given height value. + double scaleHeight(double designHeight) => optimalSize.height / designHeight; + + /// Scales a given width value. + double scaleWidth(double designWidth) => optimalSize.width / designWidth; + + /// Scales a given size value against the most constraining of the two + /// dimensions. + double scale(Size size) => + min(scaleWidth(size.width), scaleHeight(size.height)); + + /// The axis that should be used for sizing. If provided by the constructor, + /// then that axis will be used. Otherwise, the axis will be determined by + /// which dimension is closer to its ideal size. + /// + /// When the [constraintsOrientation] is [LayoutOrientation.portrait], then + /// width is the most constrained, which means all sizes are scaled based on + /// the ratio of available width to ideal width. + LayoutOrientation get constraintsOrientation => + _constraintsOrientation ??= constraintsAspectRatio.orientation; + + LayoutOrientation? _constraintsOrientation; + + /// The lowest allowed aspect ratio below which values must be clamped on the + /// most constraining dimension, which is width. + double get minAspectRatio => aspectRatioClamp.$1 ?? idealAspectRatio; + + /// The ideal aspect ratio, which is used to calculate the ideal height and + /// width. + double get idealAspectRatio => aspectRatioClamp.$2 ?? constraintsAspectRatio; + + /// The highest allowed aspect ratio above which values must be clamped on the + /// most constraining dimension, which is height. + double get maxAspectRatio => aspectRatioClamp.$3 ?? idealAspectRatio; +} + +extension on double { + /// Returns the orientation of the aspect ratio. + LayoutOrientation get orientation { + return switch (this) { + < 1 => .portrait, + > 1 => .landscape, + == 1 => .square, + _ => throw UnimplementedError('This is impossible'), + }; + } +} + +/// Returns true if [lower] is less than or equal to [higher] with [delta] +/// tolerance for floating point inaccuracies. +bool _lte(double lower, double higher, [double delta = 0.001]) { + return (higher > lower) || (higher - lower) <= delta; +} diff --git a/genlatte/genlatte-ui/lib/src/widgets/sneak_peak_carousel.dart b/genlatte/genlatte-ui/lib/src/widgets/sneak_peak_carousel.dart new file mode 100644 index 0000000..79228cf --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/widgets/sneak_peak_carousel.dart @@ -0,0 +1,223 @@ +// Copyright 2014 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/services.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +/// Horizontal carousel that shows a sneak peek of the next and previous items. +class SneakPeakCarousel extends StatefulWidget { + /// Instantiates a new [SneakPeakCarousel]. + const SneakPeakCarousel({ + required this.children, + this.previewPercentage = 0.35, + this.activeIndex, + this.onIndexChanged, + super.key, + }); + + /// The widgets to show in the carousel + final List children; + + /// The percentage of total width to show for adjacent items + final double previewPercentage; + + /// The currently active index + final int? activeIndex; + + /// Called when the active index changes + final ValueChanged? onIndexChanged; + + @override + State createState() => _SneakPeakCarouselState(); +} + +class _SneakPeakCarouselState extends State { + final ScrollController _scrollController = ScrollController(); + final FocusNode _focusNode = FocusNode(); + late int _currentIndex; + + double? _lastCardWidth; + double? _lastTotalWidth; + + @override + void initState() { + super.initState(); + _currentIndex = widget.activeIndex ?? 0; + } + + @override + void didUpdateWidget(SneakPeakCarousel oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.activeIndex != null && + widget.activeIndex != oldWidget.activeIndex) { + _currentIndex = widget.activeIndex!; + if (_lastCardWidth != null && _lastTotalWidth != null) { + _scrollToIndex( + _currentIndex, + _lastCardWidth!, + _lastTotalWidth!, + ); + } + } + } + + @override + void dispose() { + _focusNode.dispose(); + _scrollController.dispose(); + super.dispose(); + } + + void _scrollToIndex( + int targetIndex, + double cardWidth, + double totalWidth, { + bool animate = true, + }) { + if (targetIndex < 0 || targetIndex >= widget.children.length) return; + + if (!_scrollController.hasClients) return; + + final offset = targetIndex * cardWidth; + + if (animate) { + _scrollController + .animateTo( + offset, + duration: const Duration(milliseconds: 300), + curve: Curves.easeOutCubic, + ) + .then((_) { + if (mounted) { + setState(() { + _currentIndex = targetIndex; + }); + } + }) + .ignore(); + } else { + _scrollController.jumpTo(offset); + setState(() { + _currentIndex = targetIndex; + }); + } + } + + void _handlePanUpdate(DragUpdateDetails details) { + final dx = details.delta.dx; + _scrollController.jumpTo(_scrollController.offset - dx); + } + + void _handlePanEnd( + DragEndDetails details, + double cardWidth, + double totalWidth, + ) { + if (!_scrollController.hasClients) return; + + final velocity = details.primaryVelocity ?? 0; + int targetIndex = _currentIndex; + + if (velocity < -150) { + targetIndex = _currentIndex + 1; + } else if (velocity > 150) { + targetIndex = _currentIndex - 1; + } else { + // Snap to nearest index based on offset. + targetIndex = (_scrollController.offset / cardWidth).round(); + } + + // Bounds check + targetIndex = targetIndex.clamp(0, widget.children.length - 1); + + if (targetIndex != _currentIndex) { + widget.onIndexChanged?.call(targetIndex); + setState(() { + _currentIndex = targetIndex; + }); + } + + _scrollToIndex(targetIndex, cardWidth, totalWidth); + } + + void _handleArrowKeyEvent(int delta, double cardWidth, double totalWidth) { + final targetIndex = (_currentIndex + delta).clamp( + 0, + widget.children.length - 1, + ); + + if (targetIndex != _currentIndex) { + widget.onIndexChanged?.call(targetIndex); + setState(() { + _currentIndex = targetIndex; + }); + _scrollToIndex(targetIndex, cardWidth, totalWidth); + } + } + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + final totalWidth = constraints.maxWidth; + // The card takes the remaining width + final cardWidth = totalWidth * (1 - widget.previewPercentage); + + if (_lastTotalWidth == null && _currentIndex != 0) { + WidgetsBinding.instance.addPostFrameCallback((_) { + _scrollToIndex( + _currentIndex, + cardWidth, + totalWidth, + animate: false, + ); + }); + } + + _lastTotalWidth = totalWidth; + _lastCardWidth = cardWidth; + + return Focus( + focusNode: _focusNode, + autofocus: true, + onKeyEvent: (node, event) { + if (event is KeyDownEvent) { + if (event.logicalKey == LogicalKeyboardKey.arrowLeft) { + _handleArrowKeyEvent(-1, cardWidth, totalWidth); + return KeyEventResult.handled; + } else if (event.logicalKey == LogicalKeyboardKey.arrowRight) { + _handleArrowKeyEvent(1, cardWidth, totalWidth); + return KeyEventResult.handled; + } + } + return KeyEventResult.ignored; + }, + child: GestureDetector( + onHorizontalDragUpdate: _handlePanUpdate, + onHorizontalDragEnd: (details) => + _handlePanEnd(details, cardWidth, totalWidth), + child: ListView.builder( + controller: _scrollController, + physics: const NeverScrollableScrollPhysics(), + padding: EdgeInsets.symmetric( + horizontal: (totalWidth - cardWidth) / 2, + ), + scrollDirection: Axis.horizontal, + itemCount: widget.children.length, + itemBuilder: (context, index) { + return SizedBox( + width: cardWidth, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: widget.children[index], + ), + ); + }, + ), + ), + ); + }, + ); + } +} diff --git a/genlatte/genlatte-ui/lib/src/widgets/stacked_images.dart b/genlatte/genlatte-ui/lib/src/widgets/stacked_images.dart new file mode 100644 index 0000000..3881770 --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/widgets/stacked_images.dart @@ -0,0 +1,50 @@ +// Copyright 2014 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 stacks two widgets (typically images) on top of each other. +/// +/// The [top] widget is centered over the [bottom] widget and is scaled +/// according to [topScaleRatio]. +class StackedImages extends StatelessWidget { + /// Creates a [StackedImages] widget. + const StackedImages({ + required this.bottom, + required this.top, + this.topScaleRatio = 0.8, + super.key, + }); + + /// The widget at the bottom of the stack. This widget determines the + /// overall size of the [StackedImages] widget. + final Widget bottom; + + /// The widget centered on top of the [bottom] widget. + final Widget top; + + /// The scale ratio of the [top] widget relative to the [bottom] widget's + /// size. + /// + /// For example, `0.8` means the [top] widget will be 80% the size of the + /// [bottom] widget. + final double topScaleRatio; + + @override + Widget build(BuildContext context) { + return Stack( + alignment: Alignment.center, + children: [ + bottom, + Positioned.fill( + child: FractionallySizedBox( + widthFactor: topScaleRatio, + heightFactor: topScaleRatio, + child: top, + ), + ), + ], + ); + } +} diff --git a/genlatte/genlatte-ui/lib/src/widgets/triple_tap.dart b/genlatte/genlatte-ui/lib/src/widgets/triple_tap.dart new file mode 100644 index 0000000..29e0d92 --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/widgets/triple_tap.dart @@ -0,0 +1,86 @@ +// Copyright 2014 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'; + +/// Widget which calls its [onPressed] callback after three taps within the +/// [duration]. +class TripleTapDetector extends StatefulWidget { + /// Instantiates a TripleTapDetector. + const TripleTapDetector({ + required this.child, + required this.onPressed, + this.semanticLabel, + this.semanticHint, + this.duration = const Duration(milliseconds: 800), + super.key, + }); + + /// Descendant widget to display. + final Widget child; + + /// The callback to invoke after 3 rapid taps. + final VoidCallback onPressed; + + /// A description of what the widget is (e.g., "Developer Menu"). + final String? semanticLabel; + + /// A description of what happens when activated (e.g., "Triple tap to open"). + final String? semanticHint; + + /// The maximum amount of time allowed from the first tap to the third tap. + final Duration duration; + + @override + State createState() => _TripleTapDetectorState(); +} + +class _TripleTapDetectorState extends State { + int _tapCount = 0; + DateTime? _firstTapTime; + + void _handleTap() { + final now = DateTime.now(); + + // If it's the first tap or the state was reset + if (_tapCount == 0 || _firstTapTime == null) { + _firstTapTime = now; + _tapCount = 1; + } else { + // Check if the current tap is within the allowed duration window + if (now.difference(_firstTapTime!) <= widget.duration) { + _tapCount++; + + if (_tapCount == 3) { + widget.onPressed(); // Fire the callback + _tapCount = 0; // Reset the counter for the next sequence + } + } else { + // The time window expired. Treat this tap as the first tap of a new + // sequence. + _firstTapTime = now; + _tapCount = 1; + } + } + } + + @override + Widget build(BuildContext context) { + return Semantics( + // Identifies this widget as an interactive button to screen readers + button: true, + label: widget.semanticLabel, + hint: widget.semanticHint ?? 'Triple tap to activate', + onTap: widget.onPressed, + child: GestureDetector( + // We use behavior: HitTestBehavior.opaque to ensure the GestureDetector + // catches taps even if the child doesn't fill the entire space or is + // transparent. + behavior: HitTestBehavior.opaque, + onTap: _handleTap, + child: widget.child, + ), + ); + } +} diff --git a/genlatte/genlatte-ui/lib/src/widgets/typography.dart b/genlatte/genlatte-ui/lib/src/widgets/typography.dart new file mode 100644 index 0000000..ad56412 --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/widgets/typography.dart @@ -0,0 +1,15 @@ +// Copyright 2014 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:shadcn_flutter/shadcn_flutter.dart'; + +/// GenLatte specific text styles. All unspecified text should use default +/// shadcn_flutter definitions. +extension GenLatteTextExtension on Text { + /// GenLatte version of [h2] which does not have a bottom border. + TextModifier get h2_ => WrappedText( + style: (context, theme) => theme.typography.h2, + child: this, + ); +} diff --git a/genlatte/genlatte-ui/lib/src/widgets/widgets.dart b/genlatte/genlatte-ui/lib/src/widgets/widgets.dart new file mode 100644 index 0000000..de9278f --- /dev/null +++ b/genlatte/genlatte-ui/lib/src/widgets/widgets.dart @@ -0,0 +1,22 @@ +// Copyright 2014 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 'buttons.dart'; +export 'chevron_button.dart'; +export 'clipboard_value.dart'; +export 'configuration_card.dart'; +export 'dynamic_button_layout.dart'; +export 'extensions.dart'; +export 'footer.dart'; +export 'genlatte_scaffold.dart.dart'; +export 'latte_image.dart'; +export 'layout_provider.dart'; +export 'loading_dash.dart'; +export 'machine_selection.dart'; +export 'overflow_marquee.dart'; +export 'responsive_sized_box.dart'; +export 'sneak_peak_carousel.dart'; +export 'stacked_images.dart'; +export 'triple_tap.dart'; +export 'typography.dart'; diff --git a/genlatte/genlatte-ui/pubspec.yaml b/genlatte/genlatte-ui/pubspec.yaml new file mode 100644 index 0000000..2fc3e27 --- /dev/null +++ b/genlatte/genlatte-ui/pubspec.yaml @@ -0,0 +1,96 @@ +name: genlatte +description: "A new Flutter project." +publish_to: 'none' +version: 0.1.0+1 + +environment: + flutter: ^3.43.0-0.3.pre + sdk: ^3.11.0-296.4.beta + +dependencies: + clipboard: ^3.0.14 + cloud_firestore: 6.3.0 + cloud_functions: 6.2.0 + data_layer: 0.0.5 + data_layer_hive: ^0.0.2-rc.1 + equatable: ^2.0.8 + firebase_analytics: ^12.3.0 + firebase_auth: 6.4.0 + firebase_core: 4.7.0 + firebase_remote_config: ^6.4.0 + flutter: + sdk: flutter + flutter_animate: ^4.5.2 + flutter_bloc: ^9.1.1 + flutter_shaders: ^0.1.3 + flutter_svg: ^2.2.4 + flutter_web_plugins: + sdk: flutter + freezed_annotation: ^3.1.0 + genlatte_data: + path: ../genlatte-data + get_it: ^9.2.0 + go_router: ^17.0.1 + google_fonts: ^8.0.0 + hashlib: ^2.3.0 + hive_ce: ^2.19.3 + hive_ce_flutter: ^2.3.4 + json_annotation: ^4.10.0 + logging: ^1.3.0 + material_symbols_icons: ^4.2928.1 + meta: ^1.17.0 + provider: ^6.1.5+1 + shadcn_flutter: ^0.0.52 + shared_preferences: ^2.5.5 + stream_transform: ^2.1.0 + text_responsive: ^1.2.1 + thanos_snap_effect: ^0.0.9 + uuid: ^4.5.3 + video_player: ^2.11.1 + web_browser_detect: ^2.3.0 + +dev_dependencies: + bloc_test: ^10.0.0 + build_runner: ^2.10.5 + fake_cloud_firestore: 4.1.0+1 + flutter_lints: ^6.0.0 + flutter_test: + sdk: flutter + freezed: ^3.2.4 + hive_ce_generator: ^1.11.0 + json_serializable: ^6.12.0 + mocktail: ^1.0.4 + network_image_mock: ^2.1.1 + very_good_analysis: ^10.0.0 + +flutter: + uses-material-design: true + + assets: + - assets/firebase-logo.png + - assets/flutter-latte.png + - assets/flutter-logo.png + - assets/gemini-logo.png + - assets/green-check.svg + - assets/io-primary-gradient-mesh-small.jpg + - assets/latte-background.png + - assets/latte-background-thumb.png + + - assets/videos/Dash_Loading_1080px.webm + - assets/videos/Dash_Loading_1080px_H.265.mov + + # Avatars + - assets/avatars/asianFemale.png + - assets/avatars/asianMale.png + - assets/avatars/blackFemale.png + - assets/avatars/blackMale.png + - assets/avatars/caucasianFemale.png + - assets/avatars/caucasianMale.png + - assets/avatars/indianFemale.png + - assets/avatars/indianMale.png + - assets/avatars/hispanicFemale.png + - assets/avatars/hispanicMale.png + + shaders: + - assets/shaders/squish.glsl + - packages/thanos_snap_effect/shader/thanos_snap_effect.glsl diff --git a/genlatte/genlatte-ui/scripts/CONTEXT.md b/genlatte/genlatte-ui/scripts/CONTEXT.md new file mode 100644 index 0000000..7574177 --- /dev/null +++ b/genlatte/genlatte-ui/scripts/CONTEXT.md @@ -0,0 +1,24 @@ +# UI Scripts Module (`genlatte-ui/scripts/`) + +## Purpose +This directory contains convenience scripts and symlinks specifically for the `genlatte-ui` Flutter package development. It allows developers working within the UI package to access project-wide automation without navigating back to the project root. + +## Detailed File Overviews + +- **`reset_ui.sh` (Symlink)**: + - **Description**: A relative symbolic link to the main workspace reset script at `../../scripts/reset_ui.sh`. + - **Usage**: Provides a local entry point for clearing the UI sandbox and taking state snapshots. + +## Dependencies/Relationships + +- **Root Scripts**: Dynamically linked to the centralized project-wide scripts. +- **Git**: Complements the development workflow by ensuring the sandbox environment remains in a fresh Git state. + +## Usage/Exports + +### Running the Reset Script +You can execute the workspace reset directly from within this directory: +```bash +./scripts/reset_ui.sh +``` +The script is robust and will automatically identify the repository root regardless of the call location. diff --git a/genlatte/genlatte-ui/scripts/reset_ui.sh b/genlatte/genlatte-ui/scripts/reset_ui.sh new file mode 120000 index 0000000..9a7a67a --- /dev/null +++ b/genlatte/genlatte-ui/scripts/reset_ui.sh @@ -0,0 +1 @@ +../../scripts/reset_ui.sh \ No newline at end of file diff --git a/genlatte/genlatte-ui/test/CONTEXT.md b/genlatte/genlatte-ui/test/CONTEXT.md new file mode 100644 index 0000000..6f36052 --- /dev/null +++ b/genlatte/genlatte-ui/test/CONTEXT.md @@ -0,0 +1,12 @@ +# UI Package Tests + +**Purpose:** +The centralized test suite for the `genlatte-ui` Flutter package, ensuring the reliability of data models, widgets, and screen logic. + +**Subdirectories:** +- `models/`: Shared model unit tests. +- `widgets/`: Shared widget golden/interaction tests. +- `src/`: Deep logic tests mirroring the `lib/src` structure. + +**Dependencies/Relationships:** +- Utilizes `flutter_test` and `mocktail` for verification. diff --git a/genlatte/genlatte-ui/test/models/CONTEXT.md b/genlatte/genlatte-ui/test/models/CONTEXT.md new file mode 100644 index 0000000..9bdbd1e --- /dev/null +++ b/genlatte/genlatte-ui/test/models/CONTEXT.md @@ -0,0 +1,10 @@ +# Global Model Tests + +**Purpose:** +Contains unit tests for shared data models used within the UI package. + +**Detailed File Overviews:** +- `test_model.dart`: A specialized test utility or base class used to facilitate model verification. + +**Dependencies/Relationships:** +- Part of the `genlatte-ui` testing suite. diff --git a/genlatte/genlatte-ui/test/models/test_model.dart b/genlatte/genlatte-ui/test/models/test_model.dart new file mode 100644 index 0000000..0b21243 --- /dev/null +++ b/genlatte/genlatte-ui/test/models/test_model.dart @@ -0,0 +1,40 @@ +// Copyright 2014 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:data_layer/data_layer.dart' show Json; +import 'package:flutter/foundation.dart'; + +/// Test model. +@immutable +class TestModel { + /// Instantiates a [TestModel]. + const TestModel({required this.name, this.id}); + + /// Json deserialization constructor. + factory TestModel.fromJson(Json json) => TestModel( + id: json['id'] as String?, + name: json['name'] as String? ?? '', + ); + + /// Id of the object. + final String? id; + + /// Name of the object. + final String name; + + @override + String toString() => 'TestModel(id: $id, name: $name)'; + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + + return other is TestModel && other.id == id && other.name == name; + } + + @override + int get hashCode => id.hashCode ^ name.hashCode; + + Json toJson() => {'id': id, 'name': name}; +} diff --git a/genlatte/genlatte-ui/test/src/CONTEXT.md b/genlatte/genlatte-ui/test/src/CONTEXT.md new file mode 100644 index 0000000..ad75efd --- /dev/null +++ b/genlatte/genlatte-ui/test/src/CONTEXT.md @@ -0,0 +1,8 @@ +# App Tests Sub-Tree + +**Purpose:** +A structural parent grouping directory that hosts unit and widget tests for the core logic layer. + +**Implementation Details:** +- **Architecture**: Separated into testing `screens/` (for BLoCs and Widget Trees) and testing internal helpers. +- **Child Contexts**: Read child Context.md files to see specific testing strategies. diff --git a/genlatte/genlatte-ui/test/src/core/CONTEXT.md b/genlatte/genlatte-ui/test/src/core/CONTEXT.md new file mode 100644 index 0000000..23bb4e7 --- /dev/null +++ b/genlatte/genlatte-ui/test/src/core/CONTEXT.md @@ -0,0 +1,10 @@ +# Core Logic Tests + +**Purpose:** +Tests the foundational systems of the application, including routing and global configurations. + +**Subdirectories:** +- `routing/`: Redirection and access control verification. + +**Dependencies/Relationships:** +- Validates logic in `genlatte-ui/lib/src/core/`. diff --git a/genlatte/genlatte-ui/test/src/core/routing/CONTEXT.md b/genlatte/genlatte-ui/test/src/core/routing/CONTEXT.md new file mode 100644 index 0000000..b2eab8a --- /dev/null +++ b/genlatte/genlatte-ui/test/src/core/routing/CONTEXT.md @@ -0,0 +1,10 @@ +# Routing Tests + +**Purpose:** +Verifies the application's complex role-based routing and redirection logic. + +**Detailed File Overviews:** +- `redirection_test.dart`: A critical suite of tests that simulates different user authentication states and roles (Barista, Kiosk, etc.) to ensure that the `GoRouterRedirector` correctly enforces path boundaries. + +**Dependencies/Relationships:** +- Validates logic in `genlatte-ui/lib/src/core/routing/redirection.dart`. diff --git a/genlatte/genlatte-ui/test/src/core/routing/redirection_test.dart b/genlatte/genlatte-ui/test/src/core/routing/redirection_test.dart new file mode 100644 index 0000000..37d5be8 --- /dev/null +++ b/genlatte/genlatte-ui/test/src/core/routing/redirection_test.dart @@ -0,0 +1,705 @@ +// Copyright 2014 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:bloc_test/bloc_test.dart'; +import 'package:firebase_auth/firebase_auth.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:genlatte/src/core/auth/auth_bloc.dart'; +import 'package:genlatte/src/core/routing/redirection.dart'; +import 'package:genlatte/src/core/routing/router.dart'; +import 'package:genlatte/src/core/routing/routes.dart'; +import 'package:genlatte/src/role.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockAuthBloc extends MockBloc implements AuthBloc {} + +class MockUser extends Mock implements User {} + +void main() { + late MockAuthBloc mockAuthBloc; + late MockUser mockUser; + + setUp(() { + mockAuthBloc = MockAuthBloc(); + mockUser = MockUser(); + when(() => mockUser.uid).thenReturn('test_uid'); + }); + + group('Redirection Tests', () { + // Helper to create AppRouter for a specific role + AppRouter createAppRouter() { + return AppRouter(authBloc: mockAuthBloc); + } + + // Helper to test redirection logic using GoRouterRedirector directly + // This part tests the core logic in isolation + void testRedirector({ + required Role? role, + required bool authenticated, + required String currentPath, + required String? expectedRedirect, + bool ignoreQueryParameters = true, + }) { + final redirector = GoRouterRedirector(); + final authState = authenticated + ? AuthState(user: mockUser, role: role) + : const AuthState(); + final routeState = RouteState(uri: Uri.parse(currentPath)); + + final result = redirector.redirect( + routeState: routeState, + authState: authState, + ); + + String? resultWithoutQueryParams; + if (ignoreQueryParameters && result != null) { + final uri = Uri.tryParse(result); + if (uri != null) { + resultWithoutQueryParams = Uri( + path: uri.path, + // Omit query parameters. + ).toString(); + } + } + + expect( + resultWithoutQueryParams ?? result, + expectedRedirect, + reason: + 'Role $role: Authenticated=$authenticated, Current=$currentPath ' + 'should redirect to $expectedRedirect', + ); + } + + group('Barista', () { + const role = Role.barista; + + test('Unauthenticated user at /home should redirect to /login', () { + testRedirector( + role: role, + authenticated: false, + currentPath: '/home', + expectedRedirect: '/login', + ); + }); + + test('Unauthenticated user at /login should NOT redirect', () { + testRedirector( + role: role, + authenticated: false, + currentPath: '/login', + expectedRedirect: null, + ); + }); + + test('Authenticated user at /login should redirect to /home', () { + testRedirector( + role: role, + authenticated: true, + currentPath: '/login', + expectedRedirect: '/barista', + ); + }); + + test('Authenticated user at /home should NOT redirect', () { + testRedirector( + role: role, + authenticated: true, + currentPath: '/home', + expectedRedirect: null, + ); + }); + + test('Authenticated but no role at /home should redirect to /login', () { + testRedirector( + role: null, + authenticated: true, + currentPath: '/home', + expectedRedirect: '/login', + ); + }); + }); + + group('Kiosk', () { + const role = Role.kiosk; + + test('Unauthenticated user at /home should redirect to /login', () { + testRedirector( + role: role, + authenticated: false, + currentPath: '/home', + expectedRedirect: '/login', + ); + }); + + test('Unauthenticated user at /login should NOT redirect', () { + testRedirector( + role: role, + authenticated: false, + currentPath: '/login', + expectedRedirect: null, + ); + }); + + test('Authenticated user at /login should redirect to /home', () { + testRedirector( + role: role, + authenticated: true, + currentPath: '/login', + expectedRedirect: '/kiosk', + ); + }); + + test('Authenticated user at /home should NOT redirect', () { + testRedirector( + role: role, + authenticated: true, + currentPath: '/home', + expectedRedirect: null, + ); + }); + }); + + group('QueueObserver', () { + const role = Role.queueObserver; + + test('Unauthenticated user at /home should redirect to /login', () { + testRedirector( + role: role, + authenticated: false, + currentPath: '/home', + expectedRedirect: '/login', + ); + }); + + test('Unauthenticated user at /login should NOT redirect', () { + testRedirector( + role: role, + authenticated: false, + currentPath: '/login', + expectedRedirect: null, + ); + }); + + test('Authenticated user at /login should redirect to /home', () { + testRedirector( + role: role, + authenticated: true, + currentPath: '/login', + expectedRedirect: '/queue', + ); + }); + + test('Authenticated user at /home should NOT redirect', () { + testRedirector( + role: role, + authenticated: true, + currentPath: '/home', + expectedRedirect: null, + ); + }); + }); + + group('RecentOrdersObserver', () { + const role = Role.recentOrdersObserver; + + test('Unauthenticated user at /home should redirect to /login', () { + testRedirector( + role: role, + authenticated: false, + currentPath: '/home', + expectedRedirect: '/login', + ); + }); + + test('Unauthenticated user at /login should NOT redirect', () { + testRedirector( + role: role, + authenticated: false, + currentPath: '/login', + expectedRedirect: null, + ); + }); + + test('Authenticated user at /login should redirect to /home', () { + testRedirector( + role: role, + authenticated: true, + currentPath: '/login', + expectedRedirect: '/recentOrders', + ); + }); + + test('Authenticated user at /home should NOT redirect', () { + testRedirector( + role: role, + authenticated: true, + currentPath: '/home', + expectedRedirect: null, + ); + }); + }); + + group('Moderator', () { + const role = Role.moderator; + + test('Unauthenticated user at /home should redirect to /login', () { + testRedirector( + role: role, + authenticated: false, + currentPath: '/home', + expectedRedirect: '/login', + ); + }); + + test('Unauthenticated user at /login should NOT redirect', () { + testRedirector( + role: role, + authenticated: false, + currentPath: '/login', + expectedRedirect: null, + ); + }); + + test('Authenticated user at /login should redirect to /home', () { + testRedirector( + role: role, + authenticated: true, + currentPath: '/login', + expectedRedirect: '/moderator', + ); + }); + + test('Authenticated user at /home should NOT redirect', () { + testRedirector( + role: role, + authenticated: true, + currentPath: '/home', + expectedRedirect: null, + ); + }); + }); + + test('AppRouter listens to AuthBloc stream and redirects', () async { + final endState = AuthState(user: mockUser, role: .barista); + final authController = StreamController(); + + // Start unauthenticated. + when(() => mockAuthBloc.state).thenReturn(AuthState.initial()); + // Then authenticate. + when(() => mockAuthBloc.stream).thenAnswer((_) => authController.stream); + + final router = createAppRouter() + // Simulate GoRouter having completed its initial routing. + ..lastRouteState = RouteState.fromRoute(AppRoutes.initialRoute); + + authController.add(endState); + + // Expect a redirect to /barista. + await expectLater(router.allRedirects, emits('/barista')); + expect(router.lastRouteState!.path, '/barista'); + + await authController.close(); + }); + + test( + 'AppRouter creates continue parameter when protecting route', + () async { + final targetUri = Uri( + path: AppRoutes.queueRoute.path, + queryParameters: {'total': '3'}, + ); + final authController = StreamController(); + + // Start unauthenticated. + when(() => mockAuthBloc.state).thenReturn(AuthState.initial()); + when( + () => mockAuthBloc.stream, + ).thenAnswer((_) => authController.stream); + + final router = createAppRouter() + // Simulate the user attempting to navigate directly + // to a protected route with query parameters. + ..lastRouteState = RouteState(uri: targetUri); + + // Trigger the stream listener with the unauthenticated state. + authController.add(AuthState.initial()); + + final expectedUri = Uri( + path: AppRoutes.loginRoute.path, + queryParameters: {'continue': targetUri.toString()}, + ).toString(); + await expectLater(router.allRedirects, emits(expectedUri)); + + await authController.close(); + }, + ); + + test( + 'AppRouter uses continue parameter after successful login', + () async { + final endState = AuthState(user: mockUser, role: .queueObserver); + final targetUri = Uri( + path: AppRoutes.queueRoute.path, + queryParameters: {'total': '3'}, + ); + final authController = StreamController(); + + // Start unauthenticated. + when(() => mockAuthBloc.state).thenReturn(AuthState.initial()); + when( + () => mockAuthBloc.stream, + ).thenAnswer((_) => authController.stream); + + final router = createAppRouter() + // Simulate the user having been redirected to login. + ..lastRouteState = RouteState( + uri: Uri( + path: AppRoutes.loginRoute.path, + queryParameters: {'continue': targetUri.toString()}, + ), + ); + + // Trigger authenticated state. + authController.add(endState); + + await expectLater(router.allRedirects, emits(targetUri.toString())); + + await authController.close(); + }, + ); + + group('NonBaristaAwayFromBaristaHome', () { + const targetPath = '/barista'; + + test('Barista user at /barista should NOT redirect', () { + testRedirector( + role: Role.barista, + authenticated: true, + currentPath: targetPath, + expectedRedirect: null, + ); + }); + + test('Kiosk user at /barista should redirect to /kiosk', () { + testRedirector( + role: Role.kiosk, + authenticated: true, + currentPath: targetPath, + expectedRedirect: '/kiosk', + ); + }); + + test('QueueObserver user at /barista should redirect to /queue', () { + testRedirector( + role: Role.queueObserver, + authenticated: true, + currentPath: targetPath, + expectedRedirect: '/queue', + ); + }); + + test( + 'RecentOrdersObserver user at /barista should redirect to /recentOrders', + () { + testRedirector( + role: Role.recentOrdersObserver, + authenticated: true, + currentPath: targetPath, + expectedRedirect: '/recentOrders', + ); + }, + ); + + test('Anonymous user at /barista should redirect to /login', () { + testRedirector( + role: null, + authenticated: false, + currentPath: targetPath, + expectedRedirect: '/login', + ); + }); + }); + + group('NonKioskAwayFromKioskHome', () { + const targetPath = '/kiosk'; + + test('Kiosk user at /kiosk should NOT redirect', () { + testRedirector( + role: Role.kiosk, + authenticated: true, + currentPath: targetPath, + expectedRedirect: null, + ); + }); + + test('Barista user at /kiosk should redirect to /barista', () { + testRedirector( + role: Role.barista, + authenticated: true, + currentPath: targetPath, + expectedRedirect: '/barista', + ); + }); + + test('QueueObserver user at /kiosk should redirect to /queue', () { + testRedirector( + role: Role.queueObserver, + authenticated: true, + currentPath: targetPath, + expectedRedirect: '/queue', + ); + }); + + test( + 'RecentOrdersObserver user at /kiosk should redirect to /recentOrders', + () { + testRedirector( + role: Role.recentOrdersObserver, + authenticated: true, + currentPath: targetPath, + expectedRedirect: '/recentOrders', + ); + }, + ); + + test('Anonymous user at /kiosk should redirect to /login', () { + testRedirector( + role: null, + authenticated: false, + currentPath: targetPath, + expectedRedirect: '/login', + ); + }); + }); + + group('NonQueueObserverAwayFromQueue', () { + const targetPath = '/queue'; + + test('QueueObserver user at /queue should NOT redirect', () { + testRedirector( + role: Role.queueObserver, + authenticated: true, + currentPath: targetPath, + expectedRedirect: null, + ); + }); + + test('Barista user at /queue should redirect to /barista', () { + testRedirector( + role: Role.barista, + authenticated: true, + currentPath: targetPath, + expectedRedirect: '/barista', + ); + }); + + test('Kiosk user at /queue should redirect to /kiosk', () { + testRedirector( + role: Role.kiosk, + authenticated: true, + currentPath: targetPath, + expectedRedirect: '/kiosk', + ); + }); + + test( + 'RecentOrdersObserver user at /queue should redirect to /recentOrders', + () { + testRedirector( + role: Role.recentOrdersObserver, + authenticated: true, + currentPath: targetPath, + expectedRedirect: '/recentOrders', + ); + }, + ); + + test('Anonymous user at /queue should redirect to /login', () { + testRedirector( + role: null, + authenticated: false, + currentPath: targetPath, + expectedRedirect: '/login', + ); + }); + }); + + group('NonRecentOrdersObserverAwayFromRecentOrders', () { + const targetPath = '/recentOrders'; + + test( + 'RecentOrdersObserver user at /recentOrders should NOT redirect', + () { + testRedirector( + role: Role.recentOrdersObserver, + authenticated: true, + currentPath: targetPath, + expectedRedirect: null, + ); + }, + ); + + test('Barista user at /recentOrders should redirect to /barista', () { + testRedirector( + role: Role.barista, + authenticated: true, + currentPath: targetPath, + expectedRedirect: '/barista', + ); + }); + + test('Kiosk user at /recentOrders should redirect to /kiosk', () { + testRedirector( + role: Role.kiosk, + authenticated: true, + currentPath: targetPath, + expectedRedirect: '/kiosk', + ); + }); + + test('QueueObserver user at /recentOrders should redirect to /queue', () { + testRedirector( + role: Role.queueObserver, + authenticated: true, + currentPath: targetPath, + expectedRedirect: '/queue', + ); + }); + + test('Anonymous user at /recentOrders should redirect to /login', () { + testRedirector( + role: null, + authenticated: false, + currentPath: targetPath, + expectedRedirect: '/login', + ); + }); + }); + + group('NonModeratorObserverAwayFromModeration', () { + const targetPath = '/moderator'; + + test('Moderator user at /moderator should NOT redirect', () { + testRedirector( + role: Role.moderator, + authenticated: true, + currentPath: targetPath, + expectedRedirect: null, + ); + }); + + test('Barista user at /moderator should redirect to /barista', () { + testRedirector( + role: Role.barista, + authenticated: true, + currentPath: targetPath, + expectedRedirect: '/barista', + ); + }); + + test('Kiosk user at /moderator should redirect to /kiosk', () { + testRedirector( + role: Role.kiosk, + authenticated: true, + currentPath: targetPath, + expectedRedirect: '/kiosk', + ); + }); + + test('QueueObserver user at /moderator should redirect to /queue', () { + testRedirector( + role: Role.queueObserver, + authenticated: true, + currentPath: targetPath, + expectedRedirect: '/queue', + ); + }); + + test( + 'RecentOrdersObserver user at /moderator should redirect to /recentOrders', + () { + testRedirector( + role: Role.recentOrdersObserver, + authenticated: true, + currentPath: targetPath, + expectedRedirect: '/recentOrders', + ); + }, + ); + + test('Anonymous user at /moderator should redirect to /login', () { + testRedirector( + role: null, + authenticated: false, + currentPath: targetPath, + expectedRedirect: '/login', + ); + }); + }); + + group('NonModeratorsAwayFromPrinters', () { + const targetPath = '/machines'; + + test('Moderator user at /machines should NOT redirect', () { + testRedirector( + role: Role.moderator, + authenticated: true, + currentPath: targetPath, + expectedRedirect: null, + ); + }); + + test('Barista user at /machines should redirect to /barista', () { + testRedirector( + role: Role.barista, + authenticated: true, + currentPath: targetPath, + expectedRedirect: '/barista', + ); + }); + + test('Kiosk user at /machines should redirect to /kiosk', () { + testRedirector( + role: Role.kiosk, + authenticated: true, + currentPath: targetPath, + expectedRedirect: '/kiosk', + ); + }); + + test('QueueObserver user at /machines should redirect to /queue', () { + testRedirector( + role: Role.queueObserver, + authenticated: true, + currentPath: targetPath, + expectedRedirect: '/queue', + ); + }); + + test( + 'RecentOrdersObserver user at /machines should redirect to /recentOrders', + () { + testRedirector( + role: Role.recentOrdersObserver, + authenticated: true, + currentPath: targetPath, + expectedRedirect: '/recentOrders', + ); + }, + ); + + test('Anonymous user at /machines should redirect to /login', () { + testRedirector( + role: null, + authenticated: false, + currentPath: targetPath, + expectedRedirect: '/login', + ); + }); + }); + }); +} diff --git a/genlatte/genlatte-ui/test/src/screens/CONTEXT.md b/genlatte/genlatte-ui/test/src/screens/CONTEXT.md new file mode 100644 index 0000000..4633437 --- /dev/null +++ b/genlatte/genlatte-ui/test/src/screens/CONTEXT.md @@ -0,0 +1,25 @@ +# Screen Tests + +**Purpose:** +Provides automated UI and State Unit tests for the various high-level views (Barista, Kiosk, Moderator, Queue, and Recent Orders) of the `genlatte-ui` package, ensuring state integrity and visual regression protection. + +**Directory Structure Overview:** + +- `barista/`: + - Contains BLoC and Widget tests covering the `BaristaHomeBloc` and the Order Queue lists to ensure baristas can successfully claim and complete orders from mocked Firebase sources. + +- `kiosk/`: + - Validates the `KioskHomeBloc` multi-step state machine, ensuring the wizard advances forward and triggers image generation callbacks correctly when dummy user data is supplied. + +- `moderator/`: + - Tests ensuring moderators possess the ability to toggle approval statuses via the `ModeratorHomeBloc` and that Machine validation forms work correctly in isolation. + +- `queue/`: + - Complex tests assessing the mathematical properties of the `QueueHomeBloc`, verifying that independent local Sharding correctly filters mock `LatteOrder` documents based on hashing modulo logic, and validating page flips on `OrderList`. + +- `recent/`: + - Contains visual layout tests asserting the 3-Layer bubble system does not overflow and that `RecentOrdersHomeBloc` correctly sorts and limits the returned `RecentLatteImage` streams dynamically based on viewport configuration. + +**Dependencies/Relationships:** +- Actively consumes pure and mocked versions of classes from `genlatte_data` (like `LatteOrder`, `Barista`, `LatteOrderMetadata`). +- Targets `genlatte-ui/lib/src/screens/` logic. diff --git a/genlatte/genlatte-ui/test/src/screens/barista/CONTEXT.md b/genlatte/genlatte-ui/test/src/screens/barista/CONTEXT.md new file mode 100644 index 0000000..d7ed28c --- /dev/null +++ b/genlatte/genlatte-ui/test/src/screens/barista/CONTEXT.md @@ -0,0 +1,10 @@ +# Barista Feature Tests + +**Purpose:** +Tests for the Barista fulfillment features. + +**Subdirectories:** +- `home/`: BLoC verification for fulfillment workflows. + +**Dependencies/Relationships:** +- Validates logic in `genlatte-ui/lib/src/screens/barista/`. diff --git a/genlatte/genlatte-ui/test/src/screens/barista/home/CONTEXT.md b/genlatte/genlatte-ui/test/src/screens/barista/home/CONTEXT.md new file mode 100644 index 0000000..4ed35a5 --- /dev/null +++ b/genlatte/genlatte-ui/test/src/screens/barista/home/CONTEXT.md @@ -0,0 +1,10 @@ +# Barista Home Tests + +**Purpose:** +Unit tests for the Barista's fulfillment workflow business logic. + +**Detailed File Overviews:** +- `barista_home_bloc_test.dart`: Verifies the `BaristaHomeBloc`, ensuring correct handling of order prioritization and fulfillment state transitions. + +**Dependencies/Relationships:** +- Validates the BLoC in `genlatte-ui/lib/src/screens/barista/home/`. diff --git a/genlatte/genlatte-ui/test/src/screens/barista/home/barista_home_bloc_test.dart b/genlatte/genlatte-ui/test/src/screens/barista/home/barista_home_bloc_test.dart new file mode 100644 index 0000000..920db5a --- /dev/null +++ b/genlatte/genlatte-ui/test/src/screens/barista/home/barista_home_bloc_test.dart @@ -0,0 +1,309 @@ +// Copyright 2014 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:bloc_test/bloc_test.dart'; +import 'package:data_layer/data_layer.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:genlatte/src/data/data.dart' hide CompleteOrder; +import 'package:genlatte/src/screens/barista/home/barista_home_bloc.dart'; +import 'package:genlatte_data/models.dart'; +import 'package:get_it/get_it.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockBaristaRepository extends Mock implements Repository {} + +class MockLatteOrderMetadataRepository extends Mock + implements Repository {} + +class MockLatteOrdersRepository extends Mock implements LatteOrdersRepository {} + +class MockMachineRepository extends Mock implements Repository {} + +class FakeRequestDetails extends Fake implements RequestDetails {} + +class FakeBarista extends Fake implements Barista {} + +void main() { + setUpAll(() { + registerFallbackValue(FakeRequestDetails()); + registerFallbackValue(const LatteOrderMetadata(id: 'fake')); + registerFallbackValue(FakeBarista()); + }); + + group('BaristaHomeBloc', () { + late MockBaristaRepository baristaRepository; + late MockLatteOrderMetadataRepository metadataRepository; + late MockLatteOrdersRepository ordersRepository; + late MockMachineRepository machinesRepository; + + late StreamController> baristasController; + late StreamController> brewController; + late StreamController> machinesController; + + setUp(() { + baristaRepository = MockBaristaRepository(); + metadataRepository = MockLatteOrderMetadataRepository(); + ordersRepository = MockLatteOrdersRepository(); + machinesRepository = MockMachineRepository(); + + baristasController = StreamController>.broadcast(); + brewController = StreamController>.broadcast(); + machinesController = StreamController>.broadcast(); + + when( + () => ordersRepository.sendToPrinters( + imagePath: any(named: 'imagePath'), + machineName: any(named: 'machineName'), + ), + ).thenAnswer((_) async => {}); + + when( + () => baristaRepository.watchList(details: any(named: 'details')), + ).thenAnswer((_) => baristasController.stream); + + // Default mock for initial Local fetch + when( + () => baristaRepository.getItems(details: any(named: 'details')), + ).thenAnswer((_) async => []); + + when( + () => machinesRepository.watchList(details: any(named: 'details')), + ).thenAnswer((_) => machinesController.stream); + + // Default mock for initial Local fetch + when( + () => machinesRepository.getItems(details: any(named: 'details')), + ).thenAnswer((_) async => []); + + when( + () => metadataRepository.watchList( + details: any( + named: 'details', + that: isA().having( + (r) => r.filter, + 'filter', + isA(), + ), + ), + ), + ).thenAnswer((_) => brewController.stream); + + GetIt.I.registerSingleton>(baristaRepository); + GetIt.I.registerSingleton>( + metadataRepository, + ); + GetIt.I.registerSingleton(ordersRepository); + GetIt.I.registerSingleton>(machinesRepository); + }); + + tearDown(() async { + await baristasController.close(); + await brewController.close(); + await machinesController.close(); + await GetIt.I.reset(); + }); + + test('initial state is correct', () async { + final bloc = BaristaHomeBloc(); + expect(bloc.state, BaristaHomeState.initial()); + await bloc.close(); + }); + + blocTest( + 'NewBrewQueueOrders updates brewQueue', + build: () { + when(() => ordersRepository.toLattes(any())).thenAnswer( + (_) async => [ + Latte( + order: const LatteOrder(id: '1', name: 'order1'), + metadata: LatteOrderMetadata( + id: '1', + orderSubmittedTime: DateTime(2024), + ), + ), + ], + ); + return BaristaHomeBloc(); + }, + act: (bloc) => bloc.add( + NewBrewQueueOrders([ + LatteOrderMetadata( + id: '1', + orderSubmittedTime: DateTime(2024), + ), + ]), + ), + expect: () => [ + BaristaHomeState.initial().copyWith( + brewQueue: [ + Latte( + order: const LatteOrder(id: '1', name: 'order1'), + metadata: LatteOrderMetadata( + id: '1', + orderSubmittedTime: DateTime(2024), + ), + ), + ], + ), + ], + verify: (_) { + verify(() => ordersRepository.toLattes(any())).called(1); + }, + ); + + blocTest( + 'NewBaristas updates baristas map', + build: BaristaHomeBloc.new, + act: (bloc) => bloc.add( + const NewBaristas([ + Barista(id: '1', persona: BaristaPersona.asianFemale, username: 'b1'), + ]), + ), + expect: () => [ + BaristaHomeState.initial().copyWith( + baristas: { + '1': const Barista( + id: '1', + persona: BaristaPersona.asianFemale, + username: 'b1', + ), + }, + ), + ], + ); + + blocTest( + 'BaristaSignIn handles new barista', + build: () { + when( + () => baristaRepository.getItems(details: any(named: 'details')), + ).thenAnswer((_) async => []); + when(() => baristaRepository.setItem(any())).thenAnswer( + (_) async => const Barista( + id: 'new-id', + persona: BaristaPersona.asianFemale, + username: 'b1', + ), + ); + when( + () => baristaRepository.setItems( + any(), + any( + that: isA().having( + (r) => r.filter, + 'filter', + isA(), + ), + ), + ), + ).thenAnswer((_) async => []); + + return BaristaHomeBloc(); + }, + act: (bloc) => bloc.add( + const BaristaSignIn( + Barista(persona: BaristaPersona.asianFemale, username: 'b1'), + ), + ), + expect: () => [ + BaristaHomeState.initial().copyWith( + currentBarista: const Barista( + id: 'new-id', + persona: BaristaPersona.asianFemale, + username: 'b1', + ), + ), + ], + verify: (_) { + verify(() => baristaRepository.setItem(any())).called(1); + verify(() => baristaRepository.setItems(any(), any())).called(1); + }, + ); + + blocTest( + 'ClaimOrder updates metadata correctly', + build: () { + when( + () => metadataRepository.setItem(any()), + ).thenAnswer((_) async => const LatteOrderMetadata(id: '1')); + return BaristaHomeBloc(); + }, + seed: () => BaristaHomeState.initial().copyWith( + selectedMachine: const Machine(id: 'm-id', name: 'm1'), + currentBarista: const Barista( + id: 'b-id', + persona: BaristaPersona.asianFemale, + username: 'b1', + ), + brewQueue: [ + Latte( + order: const LatteOrder(id: 'O-1', name: 'order1'), + metadata: LatteOrderMetadata( + id: '1', + imageUrl: 'abc', + orderSubmittedTime: DateTime(2024), + ), + ), + ], + ), + act: (bloc) => bloc.add(const ClaimOrder('O-1')), + verify: (_) { + verify( + () => metadataRepository.setItem( + any( + that: isA() + .having((m) => m.baristaId, 'baristaId', 'b-id') + .having( + (m) => m.status, + 'status', + LatteOrderStatus.inProgress, + ), + ), + ), + ).called(1); + }, + ); + + blocTest( + 'CompleteOrder calls OrderRepository.completeOrder', + build: () { + when( + () => ordersRepository.completeOrder( + orderId: any(named: 'orderId'), + baristaId: any(named: 'baristaId'), + ), + ).thenAnswer((_) async {}); + return BaristaHomeBloc(); + }, + seed: () => BaristaHomeState.initial().copyWith( + currentBarista: const Barista( + id: 'b-id', + persona: BaristaPersona.asianFemale, + username: 'barista1', + ), + brewQueue: [ + Latte( + order: const LatteOrder(id: 'O-1', name: 'order1'), + metadata: LatteOrderMetadata( + id: '1', + imageUrl: 'abc', + orderSubmittedTime: DateTime(2024), + ), + ), + ], + ), + act: (bloc) => bloc.add(const CompleteOrder('O-1')), + verify: (_) { + verify( + () => ordersRepository.completeOrder( + orderId: 'O-1', + baristaId: 'b-id', + ), + ).called(1); + }, + ); + }); +} diff --git a/genlatte/genlatte-ui/test/src/screens/kiosk/CONTEXT.md b/genlatte/genlatte-ui/test/src/screens/kiosk/CONTEXT.md new file mode 100644 index 0000000..9f08541 --- /dev/null +++ b/genlatte/genlatte-ui/test/src/screens/kiosk/CONTEXT.md @@ -0,0 +1,10 @@ +# Kiosk Feature Tests + +**Purpose:** +Tests for the Kiosk ordering features and user interactions. + +**Subdirectories:** +- `home/`: BLoC and View verification for the ordering flow. + +**Dependencies/Relationships:** +- Validates logic in `genlatte-ui/lib/src/screens/kiosk/`. diff --git a/genlatte/genlatte-ui/test/src/screens/kiosk/home/CONTEXT.md b/genlatte/genlatte-ui/test/src/screens/kiosk/home/CONTEXT.md new file mode 100644 index 0000000..6059c5e --- /dev/null +++ b/genlatte/genlatte-ui/test/src/screens/kiosk/home/CONTEXT.md @@ -0,0 +1,11 @@ +# Kiosk Home Tests + +**Purpose:** +Comprehensive verification of the Kiosk customer journey. + +**Detailed File Overviews:** +- `kiosk_home_bloc_test.dart`: Unit tests for the state machine managing the ordering process (e.g., preference selection, art generation). +- `kiosk_home_view_test.dart`: Widget tests that verify the UI transitions and user interaction flows within the Kiosk's step-based home view. + +**Dependencies/Relationships:** +- Validates the logic and UI in `genlatte-ui/lib/src/screens/kiosk/home/`. diff --git a/genlatte/genlatte-ui/test/src/screens/kiosk/home/kiosk_home_bloc_test.dart b/genlatte/genlatte-ui/test/src/screens/kiosk/home/kiosk_home_bloc_test.dart new file mode 100644 index 0000000..77162d0 --- /dev/null +++ b/genlatte/genlatte-ui/test/src/screens/kiosk/home/kiosk_home_bloc_test.dart @@ -0,0 +1,964 @@ +// Copyright 2014 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:bloc_test/bloc_test.dart'; +import 'package:data_layer/data_layer.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:genlatte/src/data/data.dart' as repo; +import 'package:genlatte/src/screens/kiosk/home/kiosk_home_bloc.dart'; +import 'package:genlatte_data/models.dart'; +import 'package:get_it/get_it.dart'; + +class FakeRemoteSource extends LocalMemorySource with WatchableSource { + FakeRemoteSource({required super.bindings}); + + final Map>> _watchControllers = {}; + + void pushUpdate(String id, T? item) { + final result = ReadResult.success( + item, + details: RequestDetails.read(requestType: RequestType.refresh), + ); + if (_watchControllers.containsKey(id)) { + _watchControllers[id]!.add(result); + } else { + // Create it if it doesn't exist yet, so we can eagerly push + _watchControllers[id] = StreamController>.broadcast(); + _watchControllers[id]!.add(result); + } + } + + @override + SourceType get sourceType => SourceType.remote; + + @override + Stream> watch(ReadOperation operation) { + final id = operation.itemId; + if (!_watchControllers.containsKey(id)) { + _watchControllers[id] = StreamController>.broadcast(); + } + return _watchControllers[id]!.stream; + } + + @override + Stream> watchList(ReadListOperation operation) => + Stream>.empty(); + + @override + Stream> watchByIds(ReadByIdsOperation operation) => + Stream>.empty(); +} + +class FakeRepository extends Repository { + factory FakeRepository(Bindings bindings) { + final local = LocalMemorySource(bindings: bindings); + final remote = FakeRemoteSource(bindings: bindings); + final list = SourceList( + bindings: bindings, + sources: [local, remote], + ); + return FakeRepository._( + bindings: bindings, + localSource: local, + remoteSource: remote, + sourceList: list, + ); + } + FakeRepository._({ + required this.bindings, + required this.localSource, + required this.remoteSource, + required SourceList sourceList, + }) : super(sourceList); + + final Bindings bindings; + // ignore: unreachable_from_main + final LocalMemorySource localSource; + final FakeRemoteSource remoteSource; + + @override + Future setItem(T item, [RequestDetails? details]) async { + T itemCopy = item; + if (bindings.getId(itemCopy) == null) { + if (item is LatteOrder) { + itemCopy = item.copyWith(id: 'mock-assigned-id') as T; + } else if (item is LatteOrderMetadata) { + itemCopy = item.copyWith(id: 'mock-assigned-id') as T; + } + } + return super.setItem(itemCopy, details); + } +} + +class FakeLatteOrdersRepository extends FakeRepository + implements repo.LatteOrdersRepository { + factory FakeLatteOrdersRepository( + Bindings bindings, { + repo.AcceptImage? acceptImage, + repo.CompleteOrder? completeOrder, + repo.GenerateRevisedImages? generateRevisedImages, + repo.RejectImageBatch? rejectImageBatch, + repo.SendToPrinters? sendToPrinters, + repo.SubmitOrder? submitOrder, + }) { + final local = LocalMemorySource(bindings: bindings); + final remote = FakeRemoteSource(bindings: bindings); + final list = SourceList( + bindings: bindings, + sources: [local, remote], + ); + return FakeLatteOrdersRepository._( + bindings: bindings, + localSource: local, + remoteSource: remote, + sourceList: list, + acceptImage: + acceptImage ?? + ({required String imageBatchId, required String imageIndex}) async {}, + completeOrder: + completeOrder ?? + ({required String orderId, required String baristaId}) async {}, + generateRevisedImages: + generateRevisedImages ?? + ({ + required String imageBatchId, + required String imageIndex, + required Map answers, + }) async {}, + rejectImageBatch: rejectImageBatch ?? (String id) async {}, + sendToPrinters: + sendToPrinters ?? + ({required String imagePath, required String machineName}) async {}, + submitOrder: submitOrder ?? (String id) async {}, + ); + } + + FakeLatteOrdersRepository._({ + required super.bindings, + required super.localSource, + required super.remoteSource, + required super.sourceList, + required repo.AcceptImage acceptImage, + required repo.CompleteOrder completeOrder, + required repo.GenerateRevisedImages generateRevisedImages, + required repo.RejectImageBatch rejectImageBatch, + required repo.SendToPrinters sendToPrinters, + required repo.SubmitOrder submitOrder, + }) : _acceptImage = acceptImage, + _completeOrder = completeOrder, + _generateRevisedImages = generateRevisedImages, + _rejectImageBatch = rejectImageBatch, + _sendToPrinters = sendToPrinters, + _submitOrder = submitOrder, + super._(); + + @override + Future acceptImage({ + required String imageBatchId, + required String imageIndex, + }) => _acceptImage(imageBatchId: imageBatchId, imageIndex: imageIndex); + final repo.AcceptImage _acceptImage; + + @override + Future completeOrder({ + required String orderId, + required String baristaId, + }) => _completeOrder(orderId: orderId, baristaId: baristaId); + final repo.CompleteOrder _completeOrder; + + @override + Future generateRevisedImages({ + required String imageBatchId, + required String imageIndex, + required Map answers, + }) => _generateRevisedImages( + imageBatchId: imageBatchId, + imageIndex: imageIndex, + answers: answers, + ); + final repo.GenerateRevisedImages _generateRevisedImages; + + @override + Future rejectImageBatch(String imageBatchId) => + _rejectImageBatch(imageBatchId); + final repo.RejectImageBatch _rejectImageBatch; + + @override + Future sendToPrinters({ + required String imagePath, + required String machineName, + }) => _sendToPrinters(imagePath: imagePath, machineName: machineName); + final repo.SendToPrinters _sendToPrinters; + + @override + Future submitOrder(String orderId) => _submitOrder(orderId); + final repo.SubmitOrder _submitOrder; + + @override + Future> toLattes(List metadatas) async { + return []; + } +} + +void main() { + late FakeLatteOrdersRepository orderRepo; + late FakeRepository metadataRepo; + late FakeRepository imagesRepo; + late FakeRepository optionsRepo; + + const questions = [ + Question.multipleChoice( + id: 'time-of-day', + body: 'What time of day is it?', + acceptableAnswers: ['Morning', 'Evening', 'Night'], + ), + Question( + id: 'free-form', + body: 'Any other changes you want to make?', + helpText: 'This is optional', + ), + Question.zeroToOne( + id: 'how-colorful', + body: 'How colorful should the image be?', + minValueLabel: 'Black and white', + maxValueLabel: 'Technicolor', + ), + Question.negativeOneToOne( + id: 'how-chaotic', + body: 'How chaotic should the image be?', + minValueLabel: 'Calm', + maxValueLabel: 'Chaotic', + ), + ]; + + const image1 = LatteImage( + imageUrl: 'image-1.url', + prompt: 'Gemini Gemini AI man, make me an image as fast as you can', + questions: questions, + description: 'A picture of a cat', + ); + const image2 = LatteImage( + imageUrl: 'image-2.url', + prompt: 'Gemini Gemini AI man, make me an image as fast as you can', + questions: questions, + description: 'A picture of a dog', + ); + const image3 = LatteImage( + imageUrl: 'image-3.url', + prompt: 'Gemini Gemini AI man, make me an image as fast as you can', + questions: questions, + description: 'A picture of a bird', + ); + const image4 = LatteImage( + imageUrl: 'image-4.url', + prompt: 'Gemini Gemini AI man, make me an image as fast as you can', + questions: questions, + description: 'A picture of a fish', + ); + + setUp(() { + orderRepo = FakeLatteOrdersRepository(LatteOrder.bindings); + metadataRepo = FakeRepository( + LatteOrderMetadata.bindings, + ); + imagesRepo = FakeRepository(LatteImageBatch.bindings); + optionsRepo = FakeRepository(LatteOptions.bindings); + + GetIt.I.registerSingleton(orderRepo); + GetIt.I.registerSingleton>(metadataRepo); + GetIt.I.registerSingleton>(imagesRepo); + GetIt.I.registerSingleton>(optionsRepo); + }); + + tearDown(() async { + await GetIt.I.reset(); + }); + + group('KioskHomeBloc Initialization', () { + blocTest( + 'loads initial data streams and attempts to load orders', + build: KioskHomeBloc.new, + act: (bloc) async { + // allow streams to be fully subscribed + await Future.delayed(const Duration(milliseconds: 50)); + + optionsRepo.remoteSource.pushUpdate( + 'milks', + const LatteOptions( + id: 'milks', + values: [ + const LatteOption(name: 'Oat'), + const LatteOption(name: 'Almond'), + ], + ), + ); + optionsRepo.remoteSource.pushUpdate( + 'sweeteners', + const LatteOptions( + id: 'sweeteners', + values: [const LatteOption(name: 'Vanilla')], + ), + ); + }, + expect: () => [ + KioskHomeState.initial().copyWith( + milkOptions: [ + const LatteOption(name: 'Oat'), + const LatteOption(name: 'Almond'), + ], + ), + KioskHomeState.initial().copyWith( + milkOptions: [ + const LatteOption(name: 'Oat'), + const LatteOption(name: 'Almond'), + ], + sweetenerOptions: [const LatteOption(name: 'Vanilla')], + ), + ], + ); + }); + + group('KioskHomeBloc LoadPreExistingOrders Edge Cases', () { + blocTest( + 'Deletes all orders if multiple are returned from local cache', + setUp: () async { + await orderRepo.setItem(const LatteOrder(id: '1', name: 'Order 1')); + await orderRepo.setItem(const LatteOrder(id: '2', name: 'Order 2')); + }, + build: KioskHomeBloc.new, + act: (bloc) async { + await Future.delayed(const Duration(milliseconds: 50)); + }, + verify: (bloc) async { + final order1 = await orderRepo.getById( + '1', + RequestDetails.read(requestType: RequestType.local), + ); + final order2 = await orderRepo.getById( + '2', + RequestDetails.read(requestType: RequestType.local), + ); + expect(order1, isNull); + expect(order2, isNull); + }, + ); + + blocTest( + 'Deletes order if a single order is returned but metadata is missing', + setUp: () async { + await orderRepo.setItem( + const LatteOrder(id: 'only-one', name: 'But there is no metadata'), + ); + }, + build: KioskHomeBloc.new, + act: (bloc) async { + await Future.delayed(const Duration(milliseconds: 50)); + }, + verify: (bloc) async { + final retrieved = await orderRepo.getById( + 'only-one', + RequestDetails.read(requestType: RequestType.local), + ); + expect(retrieved, isNull); + }, + ); + }); + + group('KioskHomeBloc ApplyPreExistingOrder Routing Edge Cases', () { + blocTest( + 'Routing to `.name` if name is null', + setUp: () async { + await orderRepo.setItem(const LatteOrder(id: 'valid-order')); + await metadataRepo.setItem(const LatteOrderMetadata(id: 'valid-order')); + }, + build: KioskHomeBloc.new, + act: (bloc) async { + await Future.delayed(const Duration(milliseconds: 50)); + }, + expect: () => [ + isA().having( + (s) => s.currentStep, + 'currentStep', + KioskWizardStep.name, + ), + ], + ); + + blocTest( + 'Routing to `.milkAndSweetener` if milk or sweetener is null', + setUp: () async { + await orderRepo.setItem( + const LatteOrder( + id: 'valid-order', + name: 'Bob', + // milk is null + ), + ); + await metadataRepo.setItem(const LatteOrderMetadata(id: 'valid-order')); + }, + build: KioskHomeBloc.new, + act: (bloc) async { + await Future.delayed(const Duration(milliseconds: 50)); + }, + expect: () => [ + isA().having( + (s) => s.currentStep, + 'currentStep', + KioskWizardStep.milkAndSweetener, + ), + ], + ); + + blocTest( + 'Routing to `.happyPlace` if happyPlace is null', + setUp: () async { + await orderRepo.setItem( + const LatteOrder( + id: 'valid-order', + name: 'Bob', + milk: 'Oat', + sweetener: 'Vanilla', + // happyPlace is null + ), + ); + await metadataRepo.setItem(const LatteOrderMetadata(id: 'valid-order')); + }, + build: KioskHomeBloc.new, + act: (bloc) async { + await Future.delayed(const Duration(milliseconds: 50)); + }, + expect: () => [ + isA().having( + (s) => s.currentStep, + 'currentStep', + KioskWizardStep.happyPlace, + ), + ], + ); + + blocTest( + 'Routing to `.submitOrder` if metadata has an imageUrl', + setUp: () async { + await orderRepo.setItem( + const LatteOrder( + id: 'valid-order', + name: 'Bob', + milk: 'Oat', + sweetener: 'Vanilla', + happyPlace: 'A beach', + ), + ); + await metadataRepo.setItem( + const LatteOrderMetadata( + id: 'valid-order', + imageUrl: 'https://example.com/image.png', + ), + ); + }, + build: KioskHomeBloc.new, + act: (bloc) async { + await Future.delayed(const Duration(milliseconds: 50)); + }, + expect: () => [ + isA().having( + (s) => s.currentStep, + 'currentStep', + KioskWizardStep.submitOrder, + ), + ], + ); + + blocTest( + 'Routing to `.chooseAnImage` if imageBatchId exists but has no parent', + setUp: () async { + await orderRepo.setItem( + const LatteOrder( + id: 'valid-order', + name: 'Bob', + milk: 'Oat', + sweetener: 'Vanilla', + happyPlace: 'A beach', + ), + ); + await metadataRepo.setItem( + const LatteOrderMetadata( + id: 'valid-order', + imageBatchId: 'batch-1', + ), + ); + await imagesRepo.setItem( + const LatteImageBatch( + id: 'batch-1', + orderId: 'valid-order', + image0: image1, + image1: image2, + image2: image3, + image3: image4, + // parent is null + ), + ); + }, + build: KioskHomeBloc.new, + act: (bloc) async { + await Future.delayed(const Duration(milliseconds: 50)); + }, + expect: () => [ + isA().having( + (s) => s.currentStep, + 'currentStep', + KioskWizardStep.chooseAnImage, + ), + ], + ); + + blocTest( + 'Routing to `.chooseATweakedImage` if imageBatchId exists and HAS a ' + 'parent', + setUp: () async { + await orderRepo.setItem( + const LatteOrder( + id: 'valid-order', + name: 'Bob', + milk: 'Oat', + sweetener: 'Vanilla', + happyPlace: 'A beach', + ), + ); + await metadataRepo.setItem( + const LatteOrderMetadata( + id: 'valid-order', + imageBatchId: 'batch-2', + ), + ); + await imagesRepo.setItem( + const LatteImageBatch( + id: 'batch-2', + orderId: 'valid-order', + image0: image1, + image1: image2, + image2: image3, + image3: image4, + parent: LatteImageBatchParent( + id: 'batch-1', + imageIndex: '0', + ), + ), + ); + }, + build: KioskHomeBloc.new, + act: (bloc) async { + await Future.delayed(const Duration(milliseconds: 50)); + }, + expect: () => [ + isA().having( + (s) => s.currentStep, + 'currentStep', + KioskWizardStep.chooseATweakedImage, + ), + ], + ); + }); + + group('KioskHomeBloc GoBackKioskWizard Edge Cases', () { + blocTest( + 'From .submitOrder with parent -> goes to .chooseATweakedImage', + build: KioskHomeBloc.new, + seed: () => KioskHomeState.initial().copyWith( + currentStep: KioskWizardStep.submitOrder, + imagesBatch: const LatteImageBatch( + id: 'batch-2', + orderId: 'valid-order', + image0: image1, + image1: image2, + image2: image3, + image3: image4, + parent: LatteImageBatchParent( + id: 'batch-1', + imageIndex: '0', + ), + ), + ), + act: (bloc) => bloc.add(const GoBackKioskWizard()), + expect: () => [ + isA().having( + (s) => s.currentStep, + 'currentStep', + KioskWizardStep.chooseATweakedImage, + ), + ], + ); + + blocTest( + 'From .submitOrder without parent -> goes to .chooseAnImage', + build: KioskHomeBloc.new, + seed: () => KioskHomeState.initial().copyWith( + currentStep: KioskWizardStep.submitOrder, + imagesBatch: const LatteImageBatch( + id: 'batch-1', + orderId: 'valid-order', + image0: image1, + image1: image2, + image2: image3, + image3: image4, + // parent is null + ), + ), + act: (bloc) => bloc.add(const GoBackKioskWizard()), + expect: () => [ + isA().having( + (s) => s.currentStep, + 'currentStep', + KioskWizardStep.chooseAnImage, + ), + ], + ); + + blocTest( + 'Normal steps -> navigates to previous step', + build: KioskHomeBloc.new, + seed: () => KioskHomeState.initial().copyWith( + currentStep: KioskWizardStep.happyPlace, + ), + act: (bloc) => bloc.add(const GoBackKioskWizard()), + expect: () => [ + isA().having( + (s) => s.currentStep, + 'currentStep', + KioskWizardStep.milkAndSweetener, + ), + ], + ); + }); + + group('KioskHomeBloc Server Updates & Subscriptions', () { + blocTest( + 'Transition from isHappyPlaceApproved true -> false fires ' + 'HappyPlaceRejected', + build: KioskHomeBloc.new, + act: (bloc) async { + bloc.add( + const ApplyPreExistingOrder( + LatteOrder( + id: '1', + name: 'Bob', + milk: 'Oat', + sweetener: 'Vanilla', + happyPlace: 'beach', + ), + LatteOrderMetadata(id: '1', isHappyPlaceApproved: true), + ), + ); + await Future.delayed(const Duration(milliseconds: 50)); + + metadataRepo.remoteSource.pushUpdate( + '1', + const LatteOrderMetadata( + id: '1', + isHappyPlaceApproved: false, + happyPlaceModerationReason: 'Inappropriate', + ), + ); + await Future.delayed(const Duration(milliseconds: 50)); + }, + skip: 2, + expect: () => [ + isA() + .having( + (s) => s.metadata?.isHappyPlaceApproved, + 'isHappyPlaceApproved', + false, + ) + .having((s) => s.order?.happyPlace, 'happyPlace', null) + .having( + (s) => s.happyPlaceModerationEvent?.reason, + 'reason', + 'Inappropriate', + ), + ], + ); + + blocTest( + 'Changing imageBatchId cancels previous subscription and starts watching ' + 'new batch', + build: KioskHomeBloc.new, + act: (bloc) async { + bloc.add( + const ApplyPreExistingOrder( + LatteOrder(id: '1'), + LatteOrderMetadata(id: '1', imageBatchId: 'batch-1'), + ), + ); + await Future.delayed(const Duration(milliseconds: 50)); + + // push update with new batch Id + metadataRepo.remoteSource.pushUpdate( + '1', + const LatteOrderMetadata(id: '1', imageBatchId: 'batch-2'), + ); + await Future.delayed(const Duration(milliseconds: 50)); + + // push to the new batch stream explicitly + imagesRepo.remoteSource.pushUpdate( + 'batch-2', + const LatteImageBatch( + id: 'batch-2', + orderId: '1', + image0: image1, + image1: image2, + image2: image3, + image3: image4, + ), + ); + await Future.delayed(const Duration(milliseconds: 50)); + }, + skip: 2, + expect: () => [ + isA().having( + (s) => s.imagesBatch?.id, + 'imagesBatch.id', + 'batch-2', + ), + ], + ); + }); + + group('KioskHomeBloc Feature Commands', () { + blocTest( + 'SubmitUserName creates a brand new order if none exists', + build: KioskHomeBloc.new, + act: (bloc) => bloc.add(const SubmitUserName('Alice')), + expect: () => [ + isA().having( + (s) => s.isSubmitting, + 'isSubmitting', + true, + ), + isA() + .having((s) => s.order?.name, 'order.name', 'Alice') + .having((s) => s.order?.id, 'order.id', isNotNull) + .having((s) => s.isSubmitting, 'isSubmitting', true), + isA().having( + (s) => s.currentStep, + 'currentStep', + KioskWizardStep.milkAndSweetener, + ), + isA().having( + (s) => s.isSubmitting, + 'isSubmitting', + false, + ), + ], + ); + + blocTest( + 'SubmitUserName updates existing order if one exists', + build: KioskHomeBloc.new, + seed: () => KioskHomeState.initial().copyWith( + order: const LatteOrder(id: 'existing-id', name: 'Bob'), + ), + act: (bloc) => bloc.add(const SubmitUserName('Alice')), + expect: () => [ + isA().having( + (s) => s.isSubmitting, + 'isSubmitting', + true, + ), + isA() + .having((s) => s.order?.name, 'order.name', 'Alice') + .having((s) => s.order?.id, 'order.id', 'existing-id') + .having((s) => s.isSubmitting, 'isSubmitting', true), + isA().having( + (s) => s.currentStep, + 'currentStep', + KioskWizardStep.milkAndSweetener, + ), + isA().having( + (s) => s.isSubmitting, + 'isSubmitting', + false, + ), + ], + ); + + blocTest( + 'AnswerQuestion correctly immutably updates the specific question', + build: KioskHomeBloc.new, + seed: () => KioskHomeState.initial().copyWith( + questions: const [ + Question.multipleChoice( + id: 'q1', + body: 'Size?', + acceptableAnswers: ['Small', 'Large'], + ), + Question(id: 'q2', body: 'Name?'), + ], + ), + act: (bloc) => bloc.add( + const AnswerQuestion( + Question.multipleChoice( + id: 'q1', + body: 'Size?', + acceptableAnswers: ['Small', 'Large'], + ), + 'Large', + ), + ), + expect: () => [ + isA() + .having( + (s) => s.questions[0], + 'first question', + isA().having( + (q) => q.selectedValue, + 'selectedValue', + 'Large', + ), + ) + .having( + (s) => s.questions[1], + 'second question', + isA().having((q) => q.answer, 'answer', null), + ), + ], + ); + + blocTest( + 'GenerateRevisedImages sets isSubmitting to true and completes without ' + 'error', + build: KioskHomeBloc.new, + seed: () => KioskHomeState.initial().copyWith( + currentStep: KioskWizardStep.tweakImage, + imagesBatch: const LatteImageBatch( + id: 'batch-2', + orderId: 'valid-order', + image0: image1, + image1: image2, + image2: image3, + image3: image4, + ), + selectedImageIndex: 1, + questions: const [ + Question(id: 'q1', body: 'Q?'), + ], + ), + act: (bloc) => bloc.add(const GenerateRevisedImages()), + expect: () => [ + isA().having( + (s) => s.isSubmitting, + 'isSubmitting', + true, + ), + isA().having( + (s) => s.imagesBatch, + 'imagesBatch', + isNull, + ), + isA().having( + (s) => s.currentStep, + 'currentStep', + KioskWizardStep.chooseATweakedImage, + ), + isA().having( + (s) => s.isSubmitting, + 'isSubmitting', + false, + ), + ], + ); + + blocTest( + 'RejectImageBatch sets isSubmitting to true and watches parent batch', + build: KioskHomeBloc.new, + seed: () => KioskHomeState.initial().copyWith( + currentStep: KioskWizardStep.chooseATweakedImage, + imagesBatch: const LatteImageBatch( + id: 'batch-2', + orderId: 'valid-order', + image0: image1, + image1: image2, + image2: image3, + image3: image4, + parent: LatteImageBatchParent( + id: 'batch-1', + imageIndex: '0', + ), + ), + ), + act: (bloc) => bloc.add(const RejectImageBatch()), + skip: 2, + expect: () => [ + isA() + .having( + (s) => s.currentStep, + 'currentStep', + KioskWizardStep.chooseAnImage, + ) + .having((s) => s.isSubmitting, 'isSubmitting', false), + ], + ); + }); + + group('KioskHomeBloc Race Condition Protection', () { + blocTest( + 'Preserves local milk & sweetener choice if server update arrives with ' + 'null values while at .milkAndSweetener step', + build: KioskHomeBloc.new, + seed: () => KioskHomeState.initial().copyWith( + order: const LatteOrder(id: 'race-order', name: 'Bob'), + currentStep: KioskWizardStep.milkAndSweetener, + ), + act: (bloc) async { + // 1. Establish the stream by watching the order + // In the real bloc, this happens in SubmitUserName or + // ApplyPreExistingOrder. + // We'll use the private _watchOrder if we can, or just trigger an + // apply. + bloc.add( + const ApplyPreExistingOrder( + LatteOrder(id: 'race-order', name: 'Bob'), + LatteOrderMetadata(id: 'race-order'), + ), + ); + // Allow stream to be established + await Future.delayed(const Duration(milliseconds: 10)); + + // 2. User selects milk and sweetener locally + bloc + ..add(const SelectMilk('Oat')) + ..add(const SelectSweetener('Vanilla')); + + // 3. Simulate server update arriving with null values for those fields + orderRepo.remoteSource.pushUpdate( + 'race-order', + const LatteOrder( + id: 'race-order', + name: 'Bob', + ), // milk and sweetener are null + ); + + // Allow some time for the stream and microtasks + await Future.delayed(const Duration(milliseconds: 50)); + }, + // Skip the initial ApplyPreExistingOrder states + skip: 1, + expect: () => [ + // Expect state from SelectMilk + isA().having((s) => s.order?.milk, 'milk', 'Oat'), + // Expect state from SelectSweetener + isA() + .having((s) => s.order?.milk, 'milk', 'Oat') + .having((s) => s.order?.sweetener, 'sweetener', 'Vanilla'), + // The server update should NOT emit a new state that changes these + // back to null. + // It might not emit any state if the resulting order is deemed + // identical to current state. + ], + verify: (bloc) { + expect(bloc.state.order?.milk, equals('Oat')); + expect(bloc.state.order?.sweetener, equals('Vanilla')); + }, + ); + }); +} diff --git a/genlatte/genlatte-ui/test/src/screens/kiosk/home/kiosk_home_view_test.dart b/genlatte/genlatte-ui/test/src/screens/kiosk/home/kiosk_home_view_test.dart new file mode 100644 index 0000000..b4c9c5c --- /dev/null +++ b/genlatte/genlatte-ui/test/src/screens/kiosk/home/kiosk_home_view_test.dart @@ -0,0 +1,301 @@ +// Copyright 2014 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:bloc_test/bloc_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:genlatte/src/screens/kiosk/home/kiosk_home_bloc.dart'; +import 'package:genlatte/src/screens/kiosk/home/kiosk_home_view.dart'; +import 'package:genlatte/src/screens/kiosk/home/steps/steps.dart'; +import 'package:genlatte_data/models.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:network_image_mock/network_image_mock.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +class MockKioskHomeBloc extends MockBloc + implements KioskHomeBloc {} + +void main() { + late MockKioskHomeBloc mockBloc; + + setUp(() { + mockBloc = MockKioskHomeBloc(); + when(() => mockBloc.stream).thenAnswer((_) => const Stream.empty()); + }); + + Widget buildSubject({ + required Size size, + required KioskHomeBloc bloc, + }) { + return ShadcnApp( + title: 'GenLatte', + home: Scaffold( + child: MediaQuery( + data: MediaQueryData(size: size), + child: LayoutBuilder( + builder: (context, constraints) { + return KioskHomeScreen(bloc: bloc); + }, + ), + ), + ), + ); + } + + group('KioskHomeScreen layout tests', () { + const landscapeSize = Size(1920, 1080); + const portraitSize = Size(1080, 1920); + + for (final size in [landscapeSize, portraitSize]) { + final isLandscape = size.width > size.height; + final orientationName = isLandscape ? 'Landscape' : 'Portrait'; + + group('in $orientationName', () { + testWidgets('renders KioskIntro when on .intro step', (tester) async { + when(() => mockBloc.state).thenReturn( + KioskHomeState.initial().copyWith( + currentStep: KioskWizardStep.intro, + ), + ); + + await tester.pumpWidget( + buildSubject(size: size, bloc: mockBloc), + ); + await tester.pumpAndSettle(); + + expect(find.byKey(const ValueKey('step-intro')), findsOneWidget); + }); + + testWidgets('renders KioskDrinkOrderName when on .name step', ( + tester, + ) async { + when(() => mockBloc.state).thenReturn( + KioskHomeState.initial().copyWith( + currentStep: KioskWizardStep.name, + order: const LatteOrder(id: '1', name: 'Test User'), + ), + ); + + await tester.pumpWidget( + buildSubject(size: size, bloc: mockBloc), + ); + await tester.pumpAndSettle(); + + expect( + find.byWidgetPredicate( + (widget) => + widget is KioskDrinkOrderName && widget.name == 'Test User', + ), + findsOneWidget, + ); + }); + + testWidgets( + 'renders KioskDrinkMilkSweetener when on .milkAndSweetener step', + (tester) async { + when(() => mockBloc.state).thenReturn( + KioskHomeState.initial().copyWith( + currentStep: KioskWizardStep.milkAndSweetener, + order: const LatteOrder(id: '1', name: 'Test User'), + milkOptions: [ + const LatteOption(name: 'Oat'), + const LatteOption(name: 'Almond'), + ], + sweetenerOptions: [ + const LatteOption(name: 'Vanilla'), + const LatteOption(name: 'None'), + ], + ), + ); + + await tester.pumpWidget( + buildSubject(size: size, bloc: mockBloc), + ); + await tester.pumpAndSettle(); + + expect( + find.byWidgetPredicate( + (widget) => + widget is KioskDrinkMilkSweetener && + widget.username == 'Test User', + ), + findsOneWidget, + ); + }, + ); + + testWidgets('renders KioskHappyPlace when on .happyPlace step', ( + tester, + ) async { + when(() => mockBloc.state).thenReturn( + KioskHomeState.initial().copyWith( + currentStep: KioskWizardStep.happyPlace, + order: const LatteOrder( + id: '1', + name: 'Test User', + happyPlace: 'Mountain top', + ), + ), + ); + + await tester.pumpWidget( + buildSubject(size: size, bloc: mockBloc), + ); + await tester.pumpAndSettle(); + + expect( + find.byWidgetPredicate( + (widget) => + widget is KioskHappyPlace && + widget.happyPlace == 'Mountain top', + ), + findsOneWidget, + ); + }); + + testWidgets('renders KioskChooseAnImage when on .chooseAnImage step', ( + tester, + ) async { + await mockNetworkImagesFor(() async { + const mockImagesBatch = LatteImageBatch( + id: 'batch1', + orderId: '1', + image0: LatteImage( + imageUrl: 'img0', + prompt: 'opt1', + questions: [], + description: 'd1', + ), + image1: LatteImage( + imageUrl: 'img1', + prompt: 'opt2', + questions: [], + description: 'd2', + ), + image2: LatteImage( + imageUrl: 'img2', + prompt: 'opt3', + questions: [], + description: 'd3', + ), + image3: LatteImage( + imageUrl: 'img3', + prompt: 'opt4', + questions: [], + description: 'd4', + ), + ); + + when(() => mockBloc.state).thenReturn( + KioskHomeState.initial().copyWith( + currentStep: KioskWizardStep.chooseAnImage, + imagesBatch: mockImagesBatch, + selectedImageIndex: 0, + ), + ); + + await tester.pumpWidget( + buildSubject(size: size, bloc: mockBloc), + ); + await tester.pumpAndSettle(); + + expect( + find.byWidgetPredicate( + (widget) => + widget is KioskChooseAnImage && + widget.imagesBatch?.id == 'batch1', + ), + findsOneWidget, + ); + }); + }); + + testWidgets('renders KioskTweakImage when on .tweakImage step', ( + tester, + ) async { + when(() => mockBloc.state).thenReturn( + KioskHomeState.initial().copyWith( + currentStep: KioskWizardStep.tweakImage, + selectedImageIndex: 0, + questions: const [ + TextQuestion(id: 'q1', body: 'Which color?'), + TextQuestion(id: 'q2', body: 'Which style?'), + ], + ), + ); + + await tester.pumpWidget( + buildSubject(size: size, bloc: mockBloc), + ); + await tester.pumpAndSettle(); + + expect( + find.byWidgetPredicate( + (widget) => + widget is KioskTweakImage && widget.questions.length == 2, + ), + findsOneWidget, + ); + }); + + testWidgets('renders KioskSubmitOrder when on .submitOrder step', ( + tester, + ) async { + when(() => mockBloc.state).thenReturn( + KioskHomeState.initial().copyWith( + currentStep: KioskWizardStep.submitOrder, + order: const LatteOrder( + id: '1', + name: 'Test User', + sweetener: 'Sugar', + milk: 'Whole', + ), + metadata: const LatteOrderMetadata( + id: '1', + imageUrl: 'path/to/image', + ), + ), + ); + + await mockNetworkImagesFor(() async { + await tester.pumpWidget( + buildSubject(size: size, bloc: mockBloc), + ); + await tester.pumpAndSettle(); + }); + + expect( + find.byWidgetPredicate( + (widget) => widget is KioskSubmitOrder && widget.order.id == '1', + ), + findsOneWidget, + ); + }); + + testWidgets('renders KioskConfirmation when on .confirmation step', ( + tester, + ) async { + when(() => mockBloc.state).thenReturn( + KioskHomeState.initial().copyWith( + currentStep: KioskWizardStep.confirmation, + metadata: const LatteOrderMetadata(id: '1', orderNumber: 1234), + ), + ); + + await tester.pumpWidget( + buildSubject(size: size, bloc: mockBloc), + ); + await tester.pumpAndSettle(); + + expect( + find.byWidgetPredicate( + (widget) => + widget is KioskConfirmation && widget.orderNumber == 1234, + ), + findsOneWidget, + ); + }); + }); + } + }); +} diff --git a/genlatte/genlatte-ui/test/src/screens/moderator/CONTEXT.md b/genlatte/genlatte-ui/test/src/screens/moderator/CONTEXT.md new file mode 100644 index 0000000..219f8c6 --- /dev/null +++ b/genlatte/genlatte-ui/test/src/screens/moderator/CONTEXT.md @@ -0,0 +1,10 @@ +# Moderator Feature Tests + +**Purpose:** +Tests for the Moderator persona's specific features and business logic. + +**Subdirectories:** +- `home/`: BLoC verification for order moderation. + +**Dependencies/Relationships:** +- Validates logic in `genlatte-ui/lib/src/screens/moderator/`. diff --git a/genlatte/genlatte-ui/test/src/screens/moderator/home/CONTEXT.md b/genlatte/genlatte-ui/test/src/screens/moderator/home/CONTEXT.md new file mode 100644 index 0000000..5f18c62 --- /dev/null +++ b/genlatte/genlatte-ui/test/src/screens/moderator/home/CONTEXT.md @@ -0,0 +1,10 @@ +# Moderator Home Tests + +**Purpose:** +Unit tests for the Moderator persona's primary business logic. + +**Detailed File Overviews:** +- `moderator_home_bloc_test.dart`: Verifies the `ModeratorHomeBloc`, ensuring it correctly handles order approval, rejection, and stream merging for the moderation queue. + +**Dependencies/Relationships:** +- Validates the BLoC in `genlatte-ui/lib/src/screens/moderator/home/`. diff --git a/genlatte/genlatte-ui/test/src/screens/moderator/home/moderator_home_bloc_test.dart b/genlatte/genlatte-ui/test/src/screens/moderator/home/moderator_home_bloc_test.dart new file mode 100644 index 0000000..3e9dcf5 --- /dev/null +++ b/genlatte/genlatte-ui/test/src/screens/moderator/home/moderator_home_bloc_test.dart @@ -0,0 +1,292 @@ +// Copyright 2014 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:bloc_test/bloc_test.dart'; +import 'package:data_layer/data_layer.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:genlatte/src/data/data.dart'; +import 'package:genlatte/src/screens/moderator/home/moderator_home_bloc.dart'; +import 'package:genlatte_data/models.dart'; +import 'package:get_it/get_it.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockBaristaRepository extends Mock implements Repository {} + +class MockLatteOrderMetadataRepository extends Mock + implements Repository {} + +class MockLatteOrdersRepository extends Mock implements LatteOrdersRepository {} + +class FakeRequestDetails extends Fake implements RequestDetails {} + +void main() { + setUpAll(() { + registerFallbackValue(FakeRequestDetails()); + registerFallbackValue(const LatteOrderMetadata(id: 'fake')); + }); + + group('ModeratorHomeBloc', () { + late MockBaristaRepository baristaRepository; + late MockLatteOrderMetadataRepository metadataRepository; + late MockLatteOrdersRepository ordersRepository; + + late StreamController> baristasController; + late StreamController> brewController; + late StreamController> moderationController; + + setUp(() { + baristaRepository = MockBaristaRepository(); + metadataRepository = MockLatteOrderMetadataRepository(); + ordersRepository = MockLatteOrdersRepository(); + + baristasController = StreamController>.broadcast(); + brewController = StreamController>.broadcast(); + moderationController = + StreamController>.broadcast(); + + when( + () => baristaRepository.watchList(details: any(named: 'details')), + ).thenAnswer((_) => baristasController.stream); + + when( + () => metadataRepository.watchList( + details: any( + named: 'details', + that: isA().having( + (r) => r.filter, + 'filter', + isA(), + ), + ), + ), + ).thenAnswer((_) => brewController.stream); + + when( + () => metadataRepository.watchList( + details: any( + named: 'details', + that: isA().having( + (r) => r.filter, + 'filter', + isA(), + ), + ), + ), + ).thenAnswer((_) => moderationController.stream); + + GetIt.I.registerSingleton>(baristaRepository); + GetIt.I.registerSingleton>( + metadataRepository, + ); + GetIt.I.registerSingleton(ordersRepository); + }); + + tearDown(() async { + await baristasController.close(); + await brewController.close(); + await moderationController.close(); + await GetIt.I.reset(); + }); + + test('initial state is correct', () async { + final bloc = ModeratorHomeBloc(); + expect(bloc.state, ModeratorHomeState.initial()); + await bloc.close(); + }); + + blocTest( + 'NewModerateQueueOrders updates moderationQueue', + build: () { + when(() => ordersRepository.toLattes(any())).thenAnswer( + (_) async => [ + const Latte( + order: LatteOrder(id: '1', name: 'order1'), + metadata: LatteOrderMetadata(id: '1'), + ), + ], + ); + return ModeratorHomeBloc(); + }, + act: (bloc) => bloc.add( + const NewModerateQueueOrders([LatteOrderMetadata(id: '1')]), + ), + expect: () => [ + ModeratorHomeState.initial().copyWith( + moderationQueue: [ + const Latte( + order: LatteOrder(id: '1', name: 'order1'), + metadata: LatteOrderMetadata(id: '1'), + ), + ], + ), + ], + verify: (_) { + verify(() => ordersRepository.toLattes(any())).called(1); + }, + ); + + blocTest( + 'NewBaristas updates baristas map', + build: ModeratorHomeBloc.new, + act: (bloc) => bloc.add( + const NewBaristas([ + Barista(id: '1', persona: BaristaPersona.asianFemale, username: 'b1'), + ]), + ), + expect: () => [ + ModeratorHomeState.initial().copyWith( + baristas: { + '1': const Barista( + id: '1', + persona: BaristaPersona.asianFemale, + username: 'b1', + ), + }, + ), + ], + ); + + blocTest( + 'ApproveNameAndImage updates metadata correctly', + build: () { + when( + () => metadataRepository.setItem(any()), + ).thenAnswer((_) async => const LatteOrderMetadata(id: '1')); + return ModeratorHomeBloc(); + }, + seed: () => ModeratorHomeState.initial().copyWith( + moderationQueue: [ + const Latte( + order: LatteOrder(id: '1', name: 'order1'), + metadata: LatteOrderMetadata(id: '1'), + ), + ], + ), + act: (bloc) => bloc.add(const ApproveNameAndImage('1')), + verify: (_) { + verify( + () => metadataRepository.setItem( + any( + that: isA() + .having((m) => m.isNameApproved, 'isNameApproved', true) + .having((m) => m.isImageApproved, 'isImageApproved', true) + .having( + (m) => m.status, + 'status', + LatteOrderStatus.validated, + ), + ), + ), + ).called(1); + }, + ); + + blocTest( + 'RejectNameApproveImage updates metadata correctly', + build: () { + when( + () => metadataRepository.setItem(any()), + ).thenAnswer((_) async => const LatteOrderMetadata(id: '1')); + return ModeratorHomeBloc(); + }, + seed: () => ModeratorHomeState.initial().copyWith( + moderationQueue: [ + const Latte( + order: LatteOrder(id: '1', name: 'order1'), + metadata: LatteOrderMetadata(id: '1'), + ), + ], + ), + act: (bloc) => bloc.add(const RejectNameApproveImage('1')), + verify: (_) { + verify( + () => metadataRepository.setItem( + any( + that: isA() + .having((m) => m.isNameApproved, 'isNameApproved', false) + .having((m) => m.isImageApproved, 'isImageApproved', true) + .having( + (m) => m.status, + 'status', + LatteOrderStatus.validated, + ), + ), + ), + ).called(1); + }, + ); + + blocTest( + 'ApproveNameRejectImage updates metadata correctly', + build: () { + when( + () => metadataRepository.setItem(any()), + ).thenAnswer((_) async => const LatteOrderMetadata(id: '1')); + return ModeratorHomeBloc(); + }, + seed: () => ModeratorHomeState.initial().copyWith( + moderationQueue: [ + const Latte( + order: LatteOrder(id: '1', name: 'order1'), + metadata: LatteOrderMetadata(id: '1'), + ), + ], + ), + act: (bloc) => bloc.add(const ApproveNameRejectImage('1')), + verify: (_) { + verify( + () => metadataRepository.setItem( + any( + that: isA() + .having((m) => m.isNameApproved, 'isNameApproved', true) + .having((m) => m.isImageApproved, 'isImageApproved', false) + .having( + (m) => m.status, + 'status', + LatteOrderStatus.validated, + ), + ), + ), + ).called(1); + }, + ); + + blocTest( + 'RejectNameAndImage updates metadata correctly', + build: () { + when( + () => metadataRepository.setItem(any()), + ).thenAnswer((_) async => const LatteOrderMetadata(id: '1')); + return ModeratorHomeBloc(); + }, + seed: () => ModeratorHomeState.initial().copyWith( + moderationQueue: [ + const Latte( + order: LatteOrder(id: '1', name: 'order1'), + metadata: LatteOrderMetadata(id: '1'), + ), + ], + ), + act: (bloc) => bloc.add(const RejectNameAndImage('1')), + verify: (_) { + verify( + () => metadataRepository.setItem( + any( + that: isA() + .having((m) => m.isNameApproved, 'isNameApproved', false) + .having((m) => m.isImageApproved, 'isImageApproved', false) + .having( + (m) => m.status, + 'status', + LatteOrderStatus.validated, + ), + ), + ), + ).called(1); + }, + ); + }); +} diff --git a/genlatte/genlatte-ui/test/src/screens/queue/CONTEXT.md b/genlatte/genlatte-ui/test/src/screens/queue/CONTEXT.md new file mode 100644 index 0000000..b8c3c0c --- /dev/null +++ b/genlatte/genlatte-ui/test/src/screens/queue/CONTEXT.md @@ -0,0 +1,10 @@ +# Queue Feature Tests + +**Purpose:** +Tests for the autonomous Queue display features. + +**Subdirectories:** +- `home/`: BLoC verification for sharding and pagination. + +**Dependencies/Relationships:** +- Validates logic in `genlatte-ui/lib/src/screens/queue/`. diff --git a/genlatte/genlatte-ui/test/src/screens/queue/home/CONTEXT.md b/genlatte/genlatte-ui/test/src/screens/queue/home/CONTEXT.md new file mode 100644 index 0000000..7a5a15d --- /dev/null +++ b/genlatte/genlatte-ui/test/src/screens/queue/home/CONTEXT.md @@ -0,0 +1,10 @@ +# Queue Home Tests + +**Purpose:** +Unit tests for the autonomous Queue display logic. + +**Detailed File Overviews:** +- `queue_home_bloc_test.dart`: Exercises the `QueueHomeBloc`, specifically testing its deterministic sharding logic and pagination behavior. + +**Dependencies/Relationships:** +- Validates the BLoC in `genlatte-ui/lib/src/screens/queue/home/`. diff --git a/genlatte/genlatte-ui/test/src/screens/queue/home/queue_home_bloc_test.dart b/genlatte/genlatte-ui/test/src/screens/queue/home/queue_home_bloc_test.dart new file mode 100644 index 0000000..f8efb95 --- /dev/null +++ b/genlatte/genlatte-ui/test/src/screens/queue/home/queue_home_bloc_test.dart @@ -0,0 +1,511 @@ +// Copyright 2014 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. + +// ignore_for_file: avoid_redundant_argument_values + +import 'dart:async'; + +import 'package:bloc_test/bloc_test.dart'; +import 'package:data_layer/data_layer.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:genlatte/src/data/data.dart'; +import 'package:genlatte/src/data/shared_preferences_repository.dart'; +import 'package:genlatte/src/screens/queue/home/queue_home_bloc.dart'; +import 'package:genlatte/src/screens/queue/home/queue_settings.dart'; +import 'package:genlatte_data/models.dart'; +import 'package:mocktail/mocktail.dart'; + +void main() { + setUpAll(() { + registerFallbackValue(FakeRequestDetails()); + }); + + group('QueueHomeBloc', () { + late MockLatteOrderMetadataRepository metadataRepository; + late MockLatteOrdersRepository ordersRepository; + late MockSharedPreferencesRepository sharedPrefsRepository; + late StreamController> streamController; + + setUp(() { + metadataRepository = MockLatteOrderMetadataRepository(); + ordersRepository = MockLatteOrdersRepository(); + sharedPrefsRepository = MockSharedPreferencesRepository(); + when( + () => sharedPrefsRepository.setString(any(), any()), + ).thenAnswer((_) async {}); + streamController = StreamController>.broadcast(); + + when( + () => metadataRepository.watchList(details: any(named: 'details')), + ).thenAnswer((_) => streamController.stream); + }); + + tearDown(() async { + await streamController.close(); + }); + + test('initial state is correct', () async { + final bloc = QueueHomeBloc( + metadataRepository: metadataRepository, + ordersRepository: ordersRepository, + sharedPrefsRepository: sharedPrefsRepository, + initialSettings: const QueueSettingsState( + shardNumber: 42, + shardTotal: 67, + ), + ); + expect( + bloc.state, + const QueueHomeState( + settings: QueueSettingsState(shardNumber: 42, shardTotal: 67), + ), + ); + await bloc.close(); + }); + + blocTest( + 'SetRecency updates max ages', + build: () => QueueHomeBloc( + metadataRepository: metadataRepository, + ordersRepository: ordersRepository, + sharedPrefsRepository: sharedPrefsRepository, + initialSettings: const QueueSettingsState( + shardNumber: 1, + shardTotal: 2, + ), + ), + act: (bloc) => bloc.add( + const SetRecency(Duration(seconds: 10), Duration(seconds: 20)), + ), + expect: () => [ + const QueueHomeState( + settings: QueueSettingsState( + shardNumber: 1, + shardTotal: 2, + maxRecentAge: Duration(seconds: 10), + maxShowAge: Duration(seconds: 20), + ), + ), + ], + ); + + blocTest( + 'SetShard updates shards', + build: () => QueueHomeBloc( + metadataRepository: metadataRepository, + ordersRepository: ordersRepository, + sharedPrefsRepository: sharedPrefsRepository, + initialSettings: const QueueSettingsState( + shardNumber: 1, + shardTotal: 2, + ), + ), + act: (bloc) => bloc.add(const SetShard(2, 3)), + expect: () => [ + const QueueHomeState( + settings: QueueSettingsState(shardNumber: 2, shardTotal: 3), + ), + ], + ); + + blocTest( + 'SlotsCounted updates slot capacity', + build: () => QueueHomeBloc( + metadataRepository: metadataRepository, + ordersRepository: ordersRepository, + sharedPrefsRepository: sharedPrefsRepository, + initialSettings: const QueueSettingsState( + shardNumber: 1, + shardTotal: 1, + ), + ), + act: (bloc) => bloc.add(const SlotsCounted(42)), + + expect: () => contains( + isA().having( + (s) => s.uiSlotCapacity, + 'uiSlotCapacity', + 42, + ), + ), + ); + + blocTest( + 'Orders load and pages are updated on capacity', + build: () { + when(() => ordersRepository.toLattes(any())).thenAnswer( + (_) async => [ + Latte( + order: const LatteOrder(id: '1', name: 'order1'), + metadata: LatteOrderMetadata( + id: '1', + status: LatteOrderStatus.inProgress, + orderSubmittedTime: DateTime.now().subtract( + const Duration(seconds: 10), + ), + ), + ), + ], + ); + return QueueHomeBloc( + metadataRepository: metadataRepository, + ordersRepository: ordersRepository, + sharedPrefsRepository: sharedPrefsRepository, + initialSettings: const QueueSettingsState( + shardNumber: 1, + shardTotal: 1, + ), + ); + }, + seed: () => const QueueHomeState( + settings: QueueSettingsState(shardNumber: 1, shardTotal: 1), + uiSlotCapacity: 2, + ), + act: (bloc) async { + bloc.add( + OrdersUpdated([ + LatteOrderMetadata( + id: '1', + status: LatteOrderStatus.inProgress, + orderSubmittedTime: DateTime.now().subtract( + const Duration(seconds: 10), + ), + ), + ]), + ); + await Future.delayed(const Duration(milliseconds: 10)); + }, + expect: () => [ + isA() + .having((s) => s.shownPages.length, 'pages length', 1) + .having( + (s) => s.shownPages.first.orders.length, + 'orders per page', + 2, + ) + .having( + (s) => s.shownPages.first.orders.first?.order.id, + 'order id', + '1', + ), + ], + ); + + blocTest( + 'Shard change should change the UI pages', + build: () { + when(() => ordersRepository.toLattes(any())).thenAnswer( + (_) async => [ + Latte( + order: const LatteOrder(id: '2', name: 'order2'), + metadata: LatteOrderMetadata( + id: '2', + status: LatteOrderStatus.inProgress, + orderSubmittedTime: DateTime.now(), + ), + ), + ], + ); + return QueueHomeBloc( + metadataRepository: metadataRepository, + ordersRepository: ordersRepository, + sharedPrefsRepository: sharedPrefsRepository, + initialSettings: const QueueSettingsState( + shardNumber: 1, + shardTotal: 2, + ), + ); + }, + seed: () => const QueueHomeState( + settings: QueueSettingsState(shardNumber: 1, shardTotal: 2), + uiSlotCapacity: 2, + ), + act: (bloc) async { + bloc.add( + OrdersUpdated([ + LatteOrderMetadata( + id: '2', + status: LatteOrderStatus.inProgress, + orderSubmittedTime: DateTime.now(), + ), + ]), + ); + await Future.delayed(const Duration(milliseconds: 10)); + + bloc.add(const SetShard(42, 1337)); + }, + expect: () => [ + // The intermediate state from OrdersUpdated + isA(), + // Setting shard sets details + isA() + .having((s) => s.settings.shardNumber, 'shardNumber', 42) + .having( + (s) => s.settings.shardTotal, + 'shardTotal', + 1337, + ), + // RefreshPages updates the pages based on new details + isA(), + ], + ); + + blocTest( + 'Recency change should change the UI pages', + build: () { + when(() => ordersRepository.toLattes(any())).thenAnswer( + (_) async => [ + Latte( + order: const LatteOrder(id: '2', name: 'order2'), + metadata: LatteOrderMetadata( + id: '2', + status: LatteOrderStatus.inProgress, + orderSubmittedTime: DateTime.now(), + ), + ), + ], + ); + return QueueHomeBloc( + metadataRepository: metadataRepository, + ordersRepository: ordersRepository, + sharedPrefsRepository: sharedPrefsRepository, + initialSettings: const QueueSettingsState( + shardNumber: 1, + shardTotal: 2, + ), + ); + }, + seed: () => const QueueHomeState( + settings: QueueSettingsState(shardNumber: 1, shardTotal: 2), + uiSlotCapacity: 2, + ), + act: (bloc) async { + bloc.add( + OrdersUpdated([ + LatteOrderMetadata( + id: '2', + status: LatteOrderStatus.inProgress, + orderSubmittedTime: DateTime.now(), + ), + ]), + ); + await Future.delayed(const Duration(milliseconds: 10)); + + bloc.add(const SetRecency(Duration(minutes: 3), Duration(minutes: 30))); + }, + expect: () => [ + // First state from OrdersUpdated + isA(), + // Second from setting recency + isA().having( + (s) => s.settings.maxRecentAge, + 'maxRecent', + const Duration(minutes: 3), + ), + // Third from refresh + isA(), + ], + ); + + blocTest( + 'When orders arrive but there are no UI slots, ' + 'it should not try to show the orders', + build: () { + when(() => ordersRepository.toLattes(any())).thenAnswer( + (_) async => [ + Latte( + order: const LatteOrder(id: '3', name: 'order3'), + metadata: LatteOrderMetadata( + id: '3', + status: LatteOrderStatus.inProgress, + orderSubmittedTime: DateTime.now(), + ), + ), + ], + ); + return QueueHomeBloc( + metadataRepository: metadataRepository, + ordersRepository: ordersRepository, + sharedPrefsRepository: sharedPrefsRepository, + initialSettings: const QueueSettingsState( + shardNumber: 1, + shardTotal: 1, + ), + ); + }, + seed: () => const QueueHomeState( + settings: QueueSettingsState(shardNumber: 1, shardTotal: 1), + uiSlotCapacity: 0, + ), + act: (bloc) async { + await Future.delayed(const Duration(milliseconds: 10)); + + bloc.add( + OrdersUpdated([ + LatteOrderMetadata( + id: '3', + status: LatteOrderStatus.inProgress, + orderSubmittedTime: DateTime.now(), + ), + ]), + ); + await Future.delayed(const Duration(milliseconds: 10)); + }, + expect: () => [], + ); + + blocTest( + 'When there are no UI slots and orders arrive, ' + 'a new SlotsCounted with non-zero slots should produce pages immediately', + build: () { + when(() => ordersRepository.toLattes(any())).thenAnswer( + (_) async => [ + Latte( + order: const LatteOrder(id: '4', name: 'order4'), + metadata: LatteOrderMetadata( + id: '4', + status: LatteOrderStatus.inProgress, + orderSubmittedTime: DateTime.now(), + ), + ), + ], + ); + return QueueHomeBloc( + metadataRepository: metadataRepository, + ordersRepository: ordersRepository, + sharedPrefsRepository: sharedPrefsRepository, + initialSettings: const QueueSettingsState( + shardNumber: 1, + shardTotal: 1, + ), + ); + }, + seed: () => const QueueHomeState( + settings: QueueSettingsState(shardNumber: 1, shardTotal: 1), + uiSlotCapacity: 0, + ), + act: (bloc) async { + bloc.add( + OrdersUpdated([ + LatteOrderMetadata( + id: '4', + status: LatteOrderStatus.inProgress, + orderSubmittedTime: DateTime.now(), + ), + ]), + ); + await Future.delayed(const Duration(milliseconds: 10)); + + bloc.add(const SlotsCounted(4)); + }, + expect: () => [ + const QueueHomeState( + settings: QueueSettingsState(shardNumber: 1, shardTotal: 1), + uiSlotCapacity: 4, + ), + isA() + .having((s) => s.uiSlotCapacity, 'capacity', 4) + .having((s) => s.shownPages.length, 'pages length', 1), + ], + ); + + test( + 'sharding splits all orders without duplicates or omissions', + () async { + when(() => ordersRepository.toLattes(any())).thenAnswer((inv) async { + final metadatas = + inv.positionalArguments.first as List; + return metadatas + .map( + (m) => Latte( + order: LatteOrder(id: m.id ?? '', name: 'order'), + metadata: m, + ), + ) + .toList(); + }); + + final metadatas = List.generate( + 100, + (i) => LatteOrderMetadata( + id: 'order_$i', + status: LatteOrderStatus.inProgress, + orderSubmittedTime: DateTime.now(), + ), + ); + + final bloc1 = + QueueHomeBloc( + metadataRepository: metadataRepository, + ordersRepository: ordersRepository, + sharedPrefsRepository: sharedPrefsRepository, + initialSettings: const QueueSettingsState( + shardNumber: 1, + shardTotal: 2, + ), + ) + ..add(const SlotsCounted(100)) + ..add(OrdersUpdated(metadatas)); + + final bloc2 = + QueueHomeBloc( + metadataRepository: metadataRepository, + ordersRepository: ordersRepository, + sharedPrefsRepository: sharedPrefsRepository, + initialSettings: const QueueSettingsState( + shardNumber: 2, + shardTotal: 2, + ), + ) + ..add(const SlotsCounted(100)) + ..add(OrdersUpdated(metadatas)); + + await Future.delayed(const Duration(milliseconds: 100)); + + final orders1 = bloc1.state.shownPages + .expand((p) => p.orders) + .whereType() + .map((e) => e.metadata.id!) + .toSet(); + + final orders2 = bloc2.state.shownPages + .expand((p) => p.orders) + .whereType() + .map((e) => e.metadata.id!) + .toSet(); + + expect( + orders1, + isNotEmpty, + reason: 'Should select at least one order from a hundred', + ); + + expect( + orders1.intersection(orders2), + isEmpty, + reason: 'Shards should not share any orders', + ); + + expect( + orders1.union(orders2).length, + 100, + reason: 'Together, shards should show all orders without omission', + ); + + await bloc1.close(); + await bloc2.close(); + }, + ); + }); +} + +class FakeRequestDetails extends Fake implements RequestDetails {} + +class MockLatteOrderMetadataRepository extends Mock + implements Repository {} + +class MockLatteOrdersRepository extends Mock implements LatteOrdersRepository {} + +class MockSharedPreferencesRepository extends Mock + implements SharedPreferencesRepository {} diff --git a/genlatte/genlatte-ui/test/src/screens/recent/CONTEXT.md b/genlatte/genlatte-ui/test/src/screens/recent/CONTEXT.md new file mode 100644 index 0000000..0c5ad0a --- /dev/null +++ b/genlatte/genlatte-ui/test/src/screens/recent/CONTEXT.md @@ -0,0 +1,10 @@ +# Recent Orders Feature Tests + +**Purpose:** +Tests for the physics-based "Recent Orders" display logic. + +**Subdirectories:** +- `models/`: Physics calculation verification. + +**Dependencies/Relationships:** +- Validates logic in `genlatte-ui/lib/src/screens/recent_orders/`. diff --git a/genlatte/genlatte-ui/test/src/screens/recent/models/CONTEXT.md b/genlatte/genlatte-ui/test/src/screens/recent/models/CONTEXT.md new file mode 100644 index 0000000..19d76fe --- /dev/null +++ b/genlatte/genlatte-ui/test/src/screens/recent/models/CONTEXT.md @@ -0,0 +1,10 @@ +# Recent Orders Models Tests + +**Purpose:** +Verifies the physics engine math utilized by the Recent Orders screen. + +**Detailed File Overviews:** +- `wobble_calculator_test.dart`: Specifically tests the physics calculation logic for the elastic bubble collisions and "wobble" effects of latte images. + +**Dependencies/Relationships:** +- Validates the math in `genlatte-ui/lib/src/screens/recent_orders/models/`. diff --git a/genlatte/genlatte-ui/test/src/screens/recent/models/wobble_calculator_test.dart b/genlatte/genlatte-ui/test/src/screens/recent/models/wobble_calculator_test.dart new file mode 100644 index 0000000..96d24d2 --- /dev/null +++ b/genlatte/genlatte-ui/test/src/screens/recent/models/wobble_calculator_test.dart @@ -0,0 +1,62 @@ +// Copyright 2014 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_test/flutter_test.dart'; +import 'package:genlatte/src/screens/recent_orders/models/wobble_calculator.dart'; + +void main() { + group('WobbleCalculator', () { + const calculator = WobbleCalculator(wobblesPerContact: 3); + test('should return 0 strength at 0 time', () { + expect(calculator.getWobble(0 / 12).strength, closeTo(0, 0.001)); + }); + + test('should return 0.5 strength while 1/4 of the way through phase 1', () { + expect(calculator.getWobble(1 / 12).strength, closeTo(0.5, 0.001)); + }); + + test('should return 1 strength at the peak of phase 1', () { + expect(calculator.getWobble(2 / 12).strength, closeTo(1, 0.001)); + }); + + test('should return 0.5 strength while 3/4 of the way through phase 1', () { + expect(calculator.getWobble(3 / 12).strength, closeTo(0.5, 0.001)); + }); + + test('should return 0 strength at the end of phase 1', () { + expect(calculator.getWobble(4 / 12).strength, closeTo(0, 0.001)); + }); + + test( + 'should return (0.5 * 3/4) strength while 1/4 of the way through phase 2', + () { + expect( + calculator.getWobble(5 / 12).strength, + closeTo(0.5 * 0.75, 0.001), + ); + }, + ); + + test('should return 0.75 strength at the peak of phase 2', () { + expect( + calculator.getWobble(6 / 12).strength, + closeTo(0.75, 0.001), + ); + }); + + test( + 'should return 0.375 strength while 3/4 of the way through phase 2', + () { + expect( + calculator.getWobble(7 / 12).strength, + closeTo(0.5 * 0.75, 0.001), + ); + }, + ); + + test('should return 0 strength at the end of phase 2', () { + expect(calculator.getWobble(8 / 12).strength, closeTo(0, 0.001)); + }); + }); +} diff --git a/genlatte/genlatte-ui/test/src/sources/CONTEXT.md b/genlatte/genlatte-ui/test/src/sources/CONTEXT.md new file mode 100644 index 0000000..700fe72 --- /dev/null +++ b/genlatte/genlatte-ui/test/src/sources/CONTEXT.md @@ -0,0 +1,10 @@ +# Data Source Integration Tests + +**Purpose:** +Verifies the integration between the application and its remote data providers. + +**Detailed File Overviews:** +- `firestore_source_test.dart`: Exercises the `FirestoreSource`, testing data fetching, stream subscriptions, and error handling when interacting with Cloud Firestore. + +**Dependencies/Relationships:** +- Validates the implementations in `genlatte-ui/lib/src/sources/`. diff --git a/genlatte/genlatte-ui/test/src/sources/firestore_source_test.dart b/genlatte/genlatte-ui/test/src/sources/firestore_source_test.dart new file mode 100644 index 0000000..d556d48 --- /dev/null +++ b/genlatte/genlatte-ui/test/src/sources/firestore_source_test.dart @@ -0,0 +1,383 @@ +// Copyright 2014 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:cloud_firestore/cloud_firestore.dart' hide Filter; +import 'package:data_layer/data_layer.dart' hide Source; +import 'package:fake_cloud_firestore/fake_cloud_firestore.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:genlatte/src/sources/firestore_filters.dart'; +import 'package:genlatte/src/sources/firestore_source.dart'; + +import '../../models/test_model.dart'; + +// Concrete FirestoreFilter for testing +class TestFilter extends FirestoreFilter { + const TestFilter(this.filterFn); + + final Query Function(Query) filterFn; + + @override + Query apply(Query query) => filterFn(query); + + @override + String get cacheKey => 'test-filter'; + + @override + Map toParams() => {}; + + @override + Json toJson() => {}; +} + +// Unknown Filter for testing unsupported filters +class UnknownFilter extends Filter { + @override + String get cacheKey => 'unknown'; + + @override + Json toJson() => {}; + + @override + Map toParams() => {}; +} + +void main() { + group('FirestoreSource', () { + late FakeFirebaseFirestore firestore; + late FirestoreSource source; + late Bindings bindings; + late CollectionReference collection; + + setUp(() { + firestore = FakeFirebaseFirestore(); + bindings = Bindings( + getDetailUrl: (id) => ApiUrl(path: 'items/$id'), + getListUrl: () => const ApiUrl(path: 'items'), + getId: (item) => item.id, + fromJson: TestModel.fromJson, + toJson: (item) => item.toJson(), + ); + source = FirestoreSource(firestore, bindings: bindings); + collection = firestore.collection('items'); + }); + + group('getById', () { + test('returns success with data when document exists', () async { + final data = {'name': 'Test Item'}; + final docRef = await collection.add(data); + final operation = ReadOperation( + operationId: 'op1', + itemId: docRef.id, + details: RequestDetails.read(), + createdAt: DateTime.now(), + ); + + final result = await source.getById(operation); + + expect(result, isA>()); + final success = result as ReadSuccess; + expect( + success.item, + equals(TestModel.fromJson({'id': docRef.id, ...data})), + ); + }); + + test('returns success with null when document does not exist', () async { + final operation = ReadOperation( + operationId: 'op2', + itemId: 'non-existent', + details: RequestDetails.read(), + createdAt: DateTime.now(), + ); + + final result = await source.getById(operation); + + expect(result, isA>()); + final success = result as ReadSuccess; + expect(success.item, isNull); + }); + }); + + group('getByIds', () { + test('returns items and identifying missing ids', () async { + final doc1 = await collection.add({'name': 'Item 1'}); + final doc2 = await collection.add({'name': 'Item 2'}); + const missingId = 'missing-id'; + + final operation = ReadByIdsOperation( + operationId: 'op3', + itemIds: {doc1.id, doc2.id, missingId}, + details: RequestDetails.read(), + createdAt: DateTime.now(), + ); + + final result = await source.getByIds(operation); + + expect(result, isA>()); + final success = result as ReadListSuccess; + + expect(success.items.length, 2); + expect( + success.items.map((e) => e.id), + containsAll([doc1.id, doc2.id]), + ); + expect(success.missingItemIds, contains(missingId)); + }); + }); + + group('getItems', () { + test('returns all items when no filter provided', () async { + await collection.add({'name': 'Item 1'}); + await collection.add({'name': 'Item 2'}); + + final operation = ReadListOperation( + operationId: 'op4', + details: RequestDetails(), + createdAt: DateTime.now(), + ); + + final result = await source.getItems(operation); + + final success = result as ReadListSuccess; + expect(success.items.length, 2); + }); + + test('applies FirestoreFilter correctly', () async { + await collection.add({'name': 'Item 1'}); + await collection.add({'name': 'Item 2'}); + await collection.add({'name': 'Item 3'}); + + final filter = TestFilter( + (query) => query.where('name', isEqualTo: 'Item 2'), + ); + final operation = ReadListOperation( + operationId: 'op5', + details: RequestDetails(filter: filter), + createdAt: DateTime.now(), + ); + + final result = await source.getItems(operation); + + final success = result as ReadListSuccess; + expect(success.items.length, 1); + expect(success.items.every((i) => i.name == 'Item 2'), isTrue); + }); + + test('throws UnsupportedError for non-FirestoreFilter', () async { + final operation = ReadListOperation( + operationId: 'op6', + details: RequestDetails(filter: UnknownFilter()), + createdAt: DateTime.now(), + ); + + expect( + () => source.getItems(operation), + throwsUnsupportedError, + ); + }); + }); + + group('setItem', () { + test('creates new item when id is null', () async { + const item = TestModel(name: 'New Item'); + final operation = WriteOperation( + operationId: 'op7', + item: item, + details: RequestDetails(), + createdAt: DateTime.now(), + ); + + final result = await source.setItem(operation); + + expect(result, isA>()); + final success = result as WriteSuccess; + + expect(success.item.id, isNotNull); + expect(success.item.name, 'New Item'); + + final stored = await collection.doc(success.item.id).get(); + expect(stored.exists, isTrue); + }); + + test('updates existing item when id is present', () async { + final doc = await collection.add({'name': 'Original'}); + final updatedItem = TestModel.fromJson({ + 'id': doc.id, + 'name': 'Updated', + }); + + final operation = WriteOperation( + operationId: 'op9', + item: updatedItem, + details: RequestDetails(), + createdAt: DateTime.now(), + ); + + final result = await source.setItem(operation); + + expect(result, isA>()); + + final stored = await collection.doc(doc.id).get(); + expect(stored.data()!['name'], 'Updated'); + + // Ensure ID wasn't written as a field (implementation detail check) + expect(stored.data()!['id'], isNull); + }); + }); + + group('cleanData', () { + test('converts root-level Timestamps to ISO 8601 strings', () { + final timestamp = Timestamp.fromDate(DateTime.utc(2024)); + final data = { + 'name': 'Test', + 'createdAt': timestamp, + }; + final cleaned = FirestoreSource.cleanData(data); + expect(cleaned['createdAt'], equals('2024-01-01T00:00:00.000Z')); + }); + + test('converts nested Timestamps in Maps', () { + final timestamp = Timestamp.fromDate(DateTime.utc(2024)); + final data = { + 'name': 'Test', + 'metadata': { + 'updatedAt': timestamp, + }, + }; + // This currently FAILS because it returns early if no Timestamps at + // root. + final cleaned = FirestoreSource.cleanData(data); + expect( + (cleaned['metadata']! as Map)['updatedAt'], + equals('2024-01-01T00:00:00.000Z'), + ); + }); + + test('converts nested Timestamps in Lists', () { + final timestamp = Timestamp.fromDate(DateTime.utc(2024)); + final data = { + 'name': 'Test', + 'history': [ + {'at': timestamp}, + ], + }; + // This also currently FAILS for same reason + final cleaned = FirestoreSource.cleanData(data); + expect( + ((cleaned['history']! as List).first as Map)['at'], + equals('2024-01-01T00:00:00.000Z'), + ); + }); + + test('handles lists with non-Map items', () { + final data = { + 'name': 'Test', + 'tags': ['a', 'b'], + 'createdAt': Timestamp.now(), + }; + // This will FAIL currently because (e as Json) in e.map(...) + expect(() => FirestoreSource.cleanData(data), returnsNormally); + }); + + test('returns same object if empty', () { + final data = {}; + final cleaned = FirestoreSource.cleanData(data); + expect(cleaned, same(data)); + }); + + test('returns same object if no Timestamps found', () { + final data = {'name': 'Test', 'count': 1}; + final cleaned = FirestoreSource.cleanData(data); + expect(cleaned, same(data)); + }); + }); + + group('cleanDataForWrite', () { + test('converts root-level DateTimes to Timestamps', () { + final dateTime = DateTime.utc(2024); + final data = { + 'name': 'Test', + 'createdAt': dateTime, + }; + final cleaned = FirestoreSource.cleanDataForWrite(data); + expect(cleaned['createdAt'], isA()); + expect( + (cleaned['createdAt']! as Timestamp).toDate(), + equals(dateTime.toLocal()), + ); + }); + + test('converts ISO 8601 string DateTimes to Timestamps', () { + final dateTime = DateTime.utc(2024); + final data = { + 'name': 'Test', + 'createdAt': dateTime.toIso8601String(), + }; + final cleaned = FirestoreSource.cleanDataForWrite(data); + expect(cleaned['createdAt'], isA()); + expect( + (cleaned['createdAt']! as Timestamp).toDate(), + equals(dateTime.toLocal()), + ); + }); + + test('converts nested DateTimes in Maps', () { + final dateTime = DateTime.utc(2024); + final data = { + 'name': 'Test', + 'metadata': { + 'updatedAt': dateTime, + }, + }; + final cleaned = FirestoreSource.cleanDataForWrite(data); + expect((cleaned['metadata']! as Map)['updatedAt'], isA()); + expect( + ((cleaned['metadata']! as Map)['updatedAt'] as Timestamp).toDate(), + equals(dateTime.toLocal()), + ); + }); + + test('converts nested DateTimes in Lists', () { + final dateTime = DateTime.utc(2024); + final data = { + 'name': 'Test', + 'history': [ + {'at': dateTime}, + ], + }; + final cleaned = FirestoreSource.cleanDataForWrite(data); + expect( + ((cleaned['history']! as List).first as Map)['at'], + isA(), + ); + expect( + (((cleaned['history']! as List).first as Map)['at'] as Timestamp) + .toDate(), + equals(dateTime.toLocal()), + ); + }); + + test('handles lists with non-Map items', () { + final data = { + 'name': 'Test', + 'tags': ['a', 'b'], + 'createdAt': DateTime.now(), + }; + expect(() => FirestoreSource.cleanDataForWrite(data), returnsNormally); + }); + + test('returns same object if empty', () { + final data = {}; + final cleaned = FirestoreSource.cleanDataForWrite(data); + expect(cleaned, same(data)); + }); + + test('returns same object if no DateTimes found', () { + final data = {'name': 'Test', 'count': 1}; + final cleaned = FirestoreSource.cleanDataForWrite(data); + expect(cleaned, same(data)); + }); + }); + }); +} diff --git a/genlatte/genlatte-ui/test/widgets/CONTEXT.md b/genlatte/genlatte-ui/test/widgets/CONTEXT.md new file mode 100644 index 0000000..42b191f --- /dev/null +++ b/genlatte/genlatte-ui/test/widgets/CONTEXT.md @@ -0,0 +1,10 @@ +# Global Widget Tests + +**Purpose:** +Contains widget-level tests for shared components used across the application. + +**Detailed File Overviews:** +- `aspect_ratio_scalar_test.dart`: Verifies the logic of the `AspectRatioScalar` widget, ensuring correct layout calculations across different viewport dimensions. + +**Dependencies/Relationships:** +- Exercises widgets defined in `genlatte-ui/lib/src/widgets/`. diff --git a/genlatte/genlatte-ui/test/widgets/aspect_ratio_scalar_test.dart b/genlatte/genlatte-ui/test/widgets/aspect_ratio_scalar_test.dart new file mode 100644 index 0000000..411dac0 --- /dev/null +++ b/genlatte/genlatte-ui/test/widgets/aspect_ratio_scalar_test.dart @@ -0,0 +1,385 @@ +// Copyright 2014 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/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:genlatte/src/widgets/responsive_sized_box.dart'; + +BoxConstraints _fromAspectRatio(double aspectRatio, {double width = 100}) { + return BoxConstraints.tight(Size(width, width / aspectRatio)); +} + +void main() { + group('ApsectRatioScalar.orientation should', () { + test('return portrait when aspect ratio is less than 1', () { + final scalar = AspectRatioScalar(( + null, + 0.5, // does not matter + null, + ), constraints: _fromAspectRatio(0.5)); + expect(scalar.constraintsOrientation, LayoutOrientation.portrait); + }); + + test('return constraints when no ideal is provided', () { + final scalar = AspectRatioScalar(( + null, + null, + null, + ), constraints: _fromAspectRatio(0.5)); + expect(scalar.constraintsOrientation, LayoutOrientation.portrait); + }); + + test('return landscape when aspect ratio is greater than 1', () { + final scalar = AspectRatioScalar(( + null, + 0.5, // does not matter + null, + ), constraints: _fromAspectRatio(2)); + expect(scalar.constraintsOrientation, LayoutOrientation.landscape); + }); + + test('return landscape when aspect ratio is equal to 1', () { + final scalar = AspectRatioScalar(( + null, + 0.5, // does not matter + null, + ), constraints: _fromAspectRatio(1)); + expect(scalar.constraintsOrientation, LayoutOrientation.square); + }); + }); + + group('AspectRatioScalar.clampedAspectRatio should', () { + test('return the ideal aspect ratio when it is within bounds', () { + final scalar = AspectRatioScalar(( + null, + 0.5, + null, + ), constraints: _fromAspectRatio(0.5)); + expect(scalar.optimalAspectRatio, 0.5); + }); + + test('return the minimum aspect ratio when it is below the minimum', () { + final scalar = AspectRatioScalar(( + null, + 0.5, + null, + ), constraints: _fromAspectRatio(0.25)); + expect(scalar.optimalAspectRatio, 0.5); + }); + + test('return the maximum aspect ratio when it is above the maximum', () { + final scalar = AspectRatioScalar(( + null, + 0.5, + null, + ), constraints: _fromAspectRatio(0.75)); + expect(scalar.optimalAspectRatio, 0.5); + }); + + test('return the actual aspect ratio when it is within range', () { + final scalar = AspectRatioScalar(( + null, + 0.5, + 1, + ), constraints: _fromAspectRatio(0.75)); + expect(scalar.optimalAspectRatio, 0.75); + }); + + test('return the maximum aspect ratio when it is above range with max', () { + final scalar = AspectRatioScalar(( + null, + 0.5, + 1, + ), constraints: _fromAspectRatio(1.5)); + expect(scalar.optimalAspectRatio, 1); + }); + + test( + 'return the minimum aspect ratio when it is below minimum with min', + () { + final scalar = AspectRatioScalar(( + 0.25, + 0.5, + 1, + ), constraints: _fromAspectRatio(0.1)); + expect(scalar.optimalAspectRatio, 0.25); + }, + ); + }); + + group('Given a landscape object, AspectRatioScalar.optimalSize should', () { + test('fit into portrait space', () { + final scalar = AspectRatioScalar( + // Landscape object (e.g., 200x100) + (null, 2, null), + // Portrait space (exactly 100x300) + constraints: _fromAspectRatio(0.333), + ); + expect(scalar.optimalSize.width, closeTo(100, 0.0001)); + expect(scalar.optimalSize.height, closeTo(50, 0.0001)); + }); + + test('fit into portrait space with max size constraining width', () { + final scalar = AspectRatioScalar( + // Landscape object (e.g., 200x100) + (null, 2, null), + // Portrait space (exactly 100x300) + constraints: _fromAspectRatio(0.333), + maxSize: const Size(75, 75), + ); + expect(scalar.optimalSize.width, closeTo(75, 0.0001)); + expect(scalar.optimalSize.height, closeTo(37.5, 0.0001)); + }); + + test('fit into portrait space with max size constraining height', () { + final scalar = AspectRatioScalar( + // Landscape object (e.g., 200x100) + (null, 2, null), + // Portrait space (exactly 100x300) + constraints: _fromAspectRatio(0.333), + maxSize: const Size(300, 25), + ); + expect(scalar.optimalSize.width, closeTo(50, 0.0001)); + expect(scalar.optimalSize.height, closeTo(25, 0.0001)); + }); + + test('fit into landscape space', () { + final scalar = AspectRatioScalar( + // Landscape object (e.g., 200x100) + (null, 2, null), + // Landscape space (exactly 300x100) + constraints: _fromAspectRatio(3, width: 300), + ); + expect(scalar.constraintsOrientation, LayoutOrientation.landscape); + expect(scalar.optimalSize.width, closeTo(200, 0.0001), reason: 'width'); + expect(scalar.optimalSize.height, closeTo(100, 0.0001), reason: 'height'); + }); + + test('fit into landscape space with max size constraining width', () { + final scalar = AspectRatioScalar( + // Landscape object (e.g., 200x100) + (null, 2, null), + // Landscape space (exactly 300x100) + constraints: _fromAspectRatio(3, width: 300), + maxSize: const Size(250, 250), + ); + expect(scalar.optimalSize.width, closeTo(200, 0.0001), reason: 'width'); + expect(scalar.optimalSize.height, closeTo(100, 0.0001), reason: 'height'); + }); + + test('fit into landscape space with max size constraining height', () { + final scalar = AspectRatioScalar( + // Landscape object (e.g., 200x100) + (null, 2, null), + // Landscape space (exactly 300x100) + constraints: _fromAspectRatio(3, width: 300), + maxSize: const Size(300, 100), + ); + expect(scalar.optimalSize.width, closeTo(200, 0.0001), reason: 'width'); + expect(scalar.optimalSize.height, closeTo(100, 0.0001), reason: 'height'); + }); + + test('fit into square space', () { + final scalar = AspectRatioScalar( + // Landscape object (e.g., 200x100) + (null, 2, null), + // Square space (exactly 100x100) + constraints: _fromAspectRatio(1), + ); + expect(scalar.optimalSize.width, closeTo(100, 0.0001), reason: 'width'); + expect(scalar.optimalSize.height, closeTo(50, 0.0001), reason: 'height'); + }); + + test('fit into square space with max size constraining width', () { + final scalar = AspectRatioScalar( + // Landscape object (e.g., 200x100) + (null, 2, null), + // Square space (exactly 100x100) + constraints: _fromAspectRatio(1), + maxSize: const Size(90, 90), + ); + expect(scalar.optimalSize.width, closeTo(90, 0.0001), reason: 'width'); + expect(scalar.optimalSize.height, closeTo(45, 0.0001), reason: 'height'); + }); + + test('fit into square space with max size constraining height', () { + final scalar = AspectRatioScalar( + // Landscape object (e.g., 200x100) + (null, 2, null), + // Square space (exactly 100x100) + constraints: _fromAspectRatio(1), + maxSize: const Size(100, 40), + ); + expect(scalar.optimalSize.width, closeTo(80, 0.0001), reason: 'width'); + expect(scalar.optimalSize.height, closeTo(40, 0.0001), reason: 'height'); + }); + + test( + 'fit into landscape space with max size constraining both, ' + 'width is stricter', + () { + final scalar = AspectRatioScalar( + // Landscape object (e.g., 200x100) + (null, 2, null), + // Landscape space (exactly 300x100) + constraints: _fromAspectRatio(3, width: 300), + // Unconstrained size is 200x100. + // We set max size to 50x50, so both dimensions constrain. + // The scale to fit width is 50/200 = 0.25 + // The scale to fit height is 50/100 = 0.5 + // The width scale is stricter. + maxSize: const Size(50, 50), + ); + expect(scalar.optimalSize.width, closeTo(50, 0.0001)); + expect(scalar.optimalSize.height, closeTo(25, 0.0001)); + }, + ); + }); + + group('Given a portrait object, AspectRatioScalar.optimalSize should', () { + test('fit into portrait space', () { + final scalar = AspectRatioScalar( + // Portrait object (e.g., 50x100) + (null, 0.5, null), + // Portrait space (exactly 50x400) + constraints: _fromAspectRatio(0.125, width: 50), + ); + expect(scalar.constraintsOrientation, LayoutOrientation.portrait); + expect(scalar.optimalSize.width, closeTo(50, 0.0001)); + expect(scalar.optimalSize.height, closeTo(100, 0.0001)); + }); + + test('fit into portrait space with max size constraining width', () { + final scalar = AspectRatioScalar( + // Portrait object (e.g., 50x100) + (null, 0.5, null), + // Portrait space (exactly 50x400) + constraints: _fromAspectRatio(0.125, width: 50), + maxSize: const Size(25, 25), + ); + expect(scalar.constraintsOrientation, LayoutOrientation.portrait); + expect(scalar.optimalSize.width, closeTo(12.5, 0.0001)); + expect(scalar.optimalSize.height, closeTo(25, 0.0001)); + }); + + test('fit into portrait space with max size constraining height', () { + final scalar = AspectRatioScalar( + // Portrait object (e.g., 50x100) + (null, 0.5, null), + // Portrait space (exactly 50x400) + constraints: _fromAspectRatio(0.125, width: 50), + maxSize: const Size(75, 75), + ); + expect(scalar.constraintsOrientation, LayoutOrientation.portrait); + expect(scalar.optimalSize.width, closeTo(37.5, 0.0001)); + expect(scalar.optimalSize.height, closeTo(75, 0.0001)); + }); + + test('fit into landscape space', () { + final scalar = AspectRatioScalar( + // Portrait object (e.g., 50x100) + (null, 0.5, null), + // Portrait space (exactly 300x100) + constraints: _fromAspectRatio(3, width: 300), + ); + expect(scalar.constraintsOrientation, LayoutOrientation.landscape); + expect(scalar.optimalSize.width, closeTo(50, 0.0001)); + expect(scalar.optimalSize.height, closeTo(100, 0.0001)); + }); + + test( + 'fit into landscape space with max size constraining width', + () { + final scalar = AspectRatioScalar( + // Portrait object (e.g., 50x100) + (null, 0.5, null), + // Portrait space (exactly 300x100) + constraints: _fromAspectRatio(3, width: 300), + maxSize: const Size(25, 100), + ); + expect(scalar.constraintsOrientation, LayoutOrientation.landscape); + expect(scalar.optimalSize.width, closeTo(25, 0.0001)); + expect(scalar.optimalSize.height, closeTo(50, 0.0001)); + }, + ); + + test( + 'fit into landscape space with max size constraining height', + () { + final scalar = AspectRatioScalar( + // Portrait object (e.g., 50x100) + (null, 0.5, null), + // Portrait space (exactly 300x100) + constraints: _fromAspectRatio(3, width: 300), + maxSize: const Size(75, 75), + ); + expect(scalar.constraintsOrientation, LayoutOrientation.landscape); + expect(scalar.optimalSize.width, closeTo(37.5, 0.0001)); + expect(scalar.optimalSize.height, closeTo(75, 0.0001)); + }, + ); + + test('fit portrait object when available space is square', () { + final scalar = AspectRatioScalar( + // Portrait object (e.g., 50x100) + (null, 0.5, null), + // Portrait space (exactly 300x300) + constraints: _fromAspectRatio(1, width: 300), + ); + expect(scalar.constraintsOrientation, LayoutOrientation.square); + expect(scalar.optimalSize.width, closeTo(150, 0.0001)); + expect(scalar.optimalSize.height, closeTo(300, 0.0001)); + }); + + test( + 'fit into portrait space with max size constraining both, ' + 'height is stricter', + () { + final scalar = AspectRatioScalar( + // Portrait object (e.g., 50x100) + (null, 0.5, null), + // Portrait space (exactly 50x400) + constraints: _fromAspectRatio(0.125, width: 50), + // Unconstrained size is 50x100. + // We set max size to 30x30, so both dimensions constrain. + // The scale to fit width is 30/50 = 0.6 + // The scale to fit height is 30/100 = 0.3 + // The height scale is stricter. + maxSize: const Size(30, 30), + ); + expect(scalar.optimalSize.width, closeTo(15, 0.0001)); + expect(scalar.optimalSize.height, closeTo(30, 0.0001)); + }, + ); + }); + + group('Given a square object, AspectRatioScalar.optimalSize should', () { + test('fit into square space', () { + final scalar = AspectRatioScalar( + (null, 1.0, null), + constraints: _fromAspectRatio(1), + ); + expect(scalar.optimalSize.width, closeTo(100, 0.0001)); + expect(scalar.optimalSize.height, closeTo(100, 0.0001)); + }); + + test('fit into landscape space', () { + final scalar = AspectRatioScalar( + (null, 1.0, null), + constraints: _fromAspectRatio(2, width: 200), + ); + expect(scalar.optimalSize.width, closeTo(100, 0.0001)); + expect(scalar.optimalSize.height, closeTo(100, 0.0001)); + }); + + test('fit into portrait space', () { + final scalar = AspectRatioScalar( + (null, 1.0, null), + constraints: _fromAspectRatio(0.5, width: 50), + ); + expect(scalar.optimalSize.width, closeTo(50, 0.0001)); + expect(scalar.optimalSize.height, closeTo(50, 0.0001)); + }); + }); +} diff --git a/genlatte/genlatte-ui/web/CONTEXT.md b/genlatte/genlatte-ui/web/CONTEXT.md new file mode 100644 index 0000000..1ccbe40 --- /dev/null +++ b/genlatte/genlatte-ui/web/CONTEXT.md @@ -0,0 +1,10 @@ +# Web Configuration & Entry + +**Purpose:** +Contains the root assets and configurations for the Flutter Web build target. + +**Subdirectories:** +- `icons/`: PWA and browser icon assets. + +**Dependencies/Relationships:** +- Contains `index.html` and `manifest.json` which define the web application's metadata and entry point. diff --git a/genlatte/genlatte-ui/web/favicon.png b/genlatte/genlatte-ui/web/favicon.png new file mode 100644 index 0000000..8aaa46a Binary files /dev/null and b/genlatte/genlatte-ui/web/favicon.png differ diff --git a/genlatte/genlatte-ui/web/icons/CONTEXT.md b/genlatte/genlatte-ui/web/icons/CONTEXT.md new file mode 100644 index 0000000..242bd52 --- /dev/null +++ b/genlatte/genlatte-ui/web/icons/CONTEXT.md @@ -0,0 +1,11 @@ +# Progressive Web App Icons + +**Purpose:** +Standardized icon assets required for the Web build, particularly for PWA manifest compliance and browser tab identification. + +**Detailed File Overviews:** +- `Icon-192.png` / `Icon-512.png`: Standard square icons for different resolution requirements. +- `Icon-maskable-192.png` / `Icon-maskable-512.png`: Specific variants for platforms that require maskable icons (e.g., Android). + +**Dependencies/Relationships:** +- Referenced by `genlatte-ui/web/manifest.json`. diff --git a/genlatte/genlatte-ui/web/icons/Icon-192.png b/genlatte/genlatte-ui/web/icons/Icon-192.png new file mode 100644 index 0000000..b749bfe Binary files /dev/null and b/genlatte/genlatte-ui/web/icons/Icon-192.png differ diff --git a/genlatte/genlatte-ui/web/icons/Icon-512.png b/genlatte/genlatte-ui/web/icons/Icon-512.png new file mode 100644 index 0000000..88cfd48 Binary files /dev/null and b/genlatte/genlatte-ui/web/icons/Icon-512.png differ diff --git a/genlatte/genlatte-ui/web/icons/Icon-maskable-192.png b/genlatte/genlatte-ui/web/icons/Icon-maskable-192.png new file mode 100644 index 0000000..eb9b4d7 Binary files /dev/null and b/genlatte/genlatte-ui/web/icons/Icon-maskable-192.png differ diff --git a/genlatte/genlatte-ui/web/icons/Icon-maskable-512.png b/genlatte/genlatte-ui/web/icons/Icon-maskable-512.png new file mode 100644 index 0000000..d69c566 Binary files /dev/null and b/genlatte/genlatte-ui/web/icons/Icon-maskable-512.png differ diff --git a/genlatte/genlatte-ui/web/index.html b/genlatte/genlatte-ui/web/index.html new file mode 100644 index 0000000..43a019a --- /dev/null +++ b/genlatte/genlatte-ui/web/index.html @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + genlatte + + + + + + + diff --git a/genlatte/genlatte-ui/web/manifest.json b/genlatte/genlatte-ui/web/manifest.json new file mode 100644 index 0000000..b002d1a --- /dev/null +++ b/genlatte/genlatte-ui/web/manifest.json @@ -0,0 +1,35 @@ +{ + "name": "genlatte", + "short_name": "genlatte", + "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/genlatte/genlatte-ui/widgetbook/.gitignore b/genlatte/genlatte-ui/widgetbook/.gitignore new file mode 100644 index 0000000..3820a95 --- /dev/null +++ b/genlatte/genlatte-ui/widgetbook/.gitignore @@ -0,0 +1,45 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ +/coverage/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release diff --git a/genlatte/genlatte-ui/widgetbook/.metadata b/genlatte/genlatte-ui/widgetbook/.metadata new file mode 100644 index 0000000..38b5dd4 --- /dev/null +++ b/genlatte/genlatte-ui/widgetbook/.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: "a7c4c6bf74749e4562e9f47c762a815201285132" + channel: "beta" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: a7c4c6bf74749e4562e9f47c762a815201285132 + base_revision: a7c4c6bf74749e4562e9f47c762a815201285132 + - platform: web + create_revision: a7c4c6bf74749e4562e9f47c762a815201285132 + base_revision: a7c4c6bf74749e4562e9f47c762a815201285132 + + # 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/genlatte/genlatte-ui/widgetbook/CONTEXT.md b/genlatte/genlatte-ui/widgetbook/CONTEXT.md new file mode 100644 index 0000000..fdfcb35 --- /dev/null +++ b/genlatte/genlatte-ui/widgetbook/CONTEXT.md @@ -0,0 +1,42 @@ +# UI Component Library (Widgetbook) (`genlatte-ui/widgetbook/`) + +## Purpose +A dedicated Flutter project that serves as a design system and interactive catalog for the `genlatte-ui` package. It utilizes the [Widgetbook](https://widgetbook.io/) framework to allow developers to browse, interact with, and test UI components in isolation across different devices and orientations. + +## Detailed File Overviews + +- **`pubspec.yaml`**: + - **Description**: Defines dependencies for the Widgetbook project. + - **Key Dependencies**: Links to the local `genlatte-ui` package and includes `widgetbook` and `widgetbook_annotation` for generating the UI catalog. + +- **`lib/main.dart`**: + - **Description**: The entry point for the Widgetbook application. + - **Core Logic**: Initializes the `Widgetbook` app, injects the project-wide theme (`ShadcnApp`), and registers the generated directory tree. + +- **`lib/*_preview.dart`**: + - **Description**: Individual story/use-case definitions for components found in the core UI package. + - **Files included**: `chevron_button_preview.dart`, `footer_preview.dart`, `latte_image_preview.dart`, etc. + +- **`lib/main.directories.g.dart`**: + - **Description**: Generated file containing the mapping of all components annotated with `@widgetbook.UseCase`. + +## Dependencies/Relationships + +- **Core Package (`genlatte-ui`)**: Widgetbook directly depends on the core package to import and showcase its widgets. +- **Shadcn Flutter**: Uses the same component library and theme as the main application to ensure visual fidelity. +- **Development Tooling**: Integrated into the development workflow to ensure component visual consistency before integration into the main kiosk or barista apps. + +## Usage/Exports + +### Running Widgetbook +To start the component library locally: +```bash +cd genlatte-ui/widgetbook +flutter run -d chrome +``` + +### Adding New Previews +When adding new widgets to the codebase, create a corresponding `_preview.dart` file in this directory and use the `build_runner` to update the catalog: +```bash +dart run build_runner build --delete-conflicting-outputs +``` diff --git a/genlatte/genlatte-ui/widgetbook/README.md b/genlatte/genlatte-ui/widgetbook/README.md new file mode 100644 index 0000000..28e23a0 --- /dev/null +++ b/genlatte/genlatte-ui/widgetbook/README.md @@ -0,0 +1,3 @@ +# widgetbook + +A new Flutter project. diff --git a/genlatte/genlatte-ui/widgetbook/analysis_options.yaml b/genlatte/genlatte-ui/widgetbook/analysis_options.yaml new file mode 100644 index 0000000..6bb291a --- /dev/null +++ b/genlatte/genlatte-ui/widgetbook/analysis_options.yaml @@ -0,0 +1,8 @@ +include: package:flutter_lints/flutter.yaml + +analyzer: + errors: + implementation_imports: ignore + +formatter: + trailing_commas: preserve diff --git a/genlatte/genlatte-ui/widgetbook/assets/CONTEXT.md b/genlatte/genlatte-ui/widgetbook/assets/CONTEXT.md new file mode 100644 index 0000000..a6fcddc --- /dev/null +++ b/genlatte/genlatte-ui/widgetbook/assets/CONTEXT.md @@ -0,0 +1,11 @@ +# Widgetbook Assets + +**Purpose:** +Storage for static binary assets used within the Widgetbook previews. + +**Subdirectories:** +- `videos/`: Loading screen video previews. +- `shaders/`: Custom shader previews. + +**Dependencies/Relationships:** +- Declared in `genlatte-ui/widgetbook/pubspec.yaml`. diff --git a/genlatte/genlatte-ui/widgetbook/assets/firebase-logo.png b/genlatte/genlatte-ui/widgetbook/assets/firebase-logo.png new file mode 100644 index 0000000..5524d8d Binary files /dev/null and b/genlatte/genlatte-ui/widgetbook/assets/firebase-logo.png differ diff --git a/genlatte/genlatte-ui/widgetbook/assets/flutter-logo.png b/genlatte/genlatte-ui/widgetbook/assets/flutter-logo.png new file mode 100644 index 0000000..4d545bd Binary files /dev/null and b/genlatte/genlatte-ui/widgetbook/assets/flutter-logo.png differ diff --git a/genlatte/genlatte-ui/widgetbook/assets/gemini-logo.png b/genlatte/genlatte-ui/widgetbook/assets/gemini-logo.png new file mode 100644 index 0000000..6126770 Binary files /dev/null and b/genlatte/genlatte-ui/widgetbook/assets/gemini-logo.png differ diff --git a/genlatte/genlatte-ui/widgetbook/assets/latte-background.png b/genlatte/genlatte-ui/widgetbook/assets/latte-background.png new file mode 100644 index 0000000..77cef4c Binary files /dev/null and b/genlatte/genlatte-ui/widgetbook/assets/latte-background.png differ diff --git a/genlatte/genlatte-ui/widgetbook/assets/shaders/CONTEXT.md b/genlatte/genlatte-ui/widgetbook/assets/shaders/CONTEXT.md new file mode 100644 index 0000000..48b0c5d --- /dev/null +++ b/genlatte/genlatte-ui/widgetbook/assets/shaders/CONTEXT.md @@ -0,0 +1,10 @@ +# Widgetbook Shader Assets + +**Purpose:** +Mirrors the application's shader assets for use within Widgetbook component previews. + +**Detailed File Overviews:** +- `squish.glsl`: Used to preview shader-based effects (like `SquishWidget`) in Widgetbook. + +**Dependencies/Relationships:** +- Referenced by `genlatte-ui/widgetbook/lib/squish_widget_preview.dart`. diff --git a/genlatte/genlatte-ui/widgetbook/assets/shaders/squish.glsl b/genlatte/genlatte-ui/widgetbook/assets/shaders/squish.glsl new file mode 120000 index 0000000..20b8839 --- /dev/null +++ b/genlatte/genlatte-ui/widgetbook/assets/shaders/squish.glsl @@ -0,0 +1 @@ +../../../assets/shaders/squish.glsl \ No newline at end of file diff --git a/genlatte/genlatte-ui/widgetbook/assets/videos/CONTEXT.md b/genlatte/genlatte-ui/widgetbook/assets/videos/CONTEXT.md new file mode 100644 index 0000000..3044adc --- /dev/null +++ b/genlatte/genlatte-ui/widgetbook/assets/videos/CONTEXT.md @@ -0,0 +1,10 @@ +# Widgetbook Video Assets + +**Purpose:** +Mirrors the application's video assets for use within the Widgetbook component previews. + +**Detailed File Overviews:** +- `Dash_Loading_1080px.webm`: Used to preview the `LoadingDash` widget in Widgetbook. + +**Dependencies/Relationships:** +- Referenced by `genlatte-ui/widgetbook/lib/loading_dash_preview.dart`. diff --git a/genlatte/genlatte-ui/widgetbook/assets/videos/Dash_Loading_1080px.webm b/genlatte/genlatte-ui/widgetbook/assets/videos/Dash_Loading_1080px.webm new file mode 100644 index 0000000..299b6da Binary files /dev/null and b/genlatte/genlatte-ui/widgetbook/assets/videos/Dash_Loading_1080px.webm differ diff --git a/genlatte/genlatte-ui/widgetbook/assets/videos/Dash_Loading_1080px_H.265.mov b/genlatte/genlatte-ui/widgetbook/assets/videos/Dash_Loading_1080px_H.265.mov new file mode 120000 index 0000000..c116e0d --- /dev/null +++ b/genlatte/genlatte-ui/widgetbook/assets/videos/Dash_Loading_1080px_H.265.mov @@ -0,0 +1 @@ +../../../assets/videos/Dash_loading_1080px_H.265.mov \ No newline at end of file diff --git a/genlatte/genlatte-ui/widgetbook/lib/CONTEXT.md b/genlatte/genlatte-ui/widgetbook/lib/CONTEXT.md new file mode 100644 index 0000000..6168890 --- /dev/null +++ b/genlatte/genlatte-ui/widgetbook/lib/CONTEXT.md @@ -0,0 +1,12 @@ +# Widgetbook Component Previews + +**Purpose:** +Defines individual stories and previews for UI components using the Widgetbook framework. + +**Detailed File Overviews:** +- `main.dart`: Entry point for the Widgetbook application. +- `*_preview.dart`: Individual preview files for various shared widgets (buttons, cards, sliders, etc.). +- `main.directories.g.dart`: Autogenerated directory mapping for Widgetbook's navigation sidebar. + +**Dependencies/Relationships:** +- Previews widgets imported from `genlatte-ui/lib/src/widgets/`. diff --git a/genlatte/genlatte-ui/widgetbook/lib/chevon_button_preview.dart b/genlatte/genlatte-ui/widgetbook/lib/chevon_button_preview.dart new file mode 100644 index 0000000..e74c695 --- /dev/null +++ b/genlatte/genlatte-ui/widgetbook/lib/chevon_button_preview.dart @@ -0,0 +1,55 @@ +// Copyright 2014 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:shadcn_flutter/shadcn_flutter.dart'; +import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook; + +import 'package:genlatte/src/widgets/chevron_button.dart'; +import 'package:widgetbook/widgetbook.dart'; + +@widgetbook.UseCase(name: 'Vertical', type: ChevronButton) +Widget buildVerticalChevronButtonUseCase(BuildContext context) { + return Center( + child: ChevronButton.vertical( + scale: context.knobs.double.slider( + label: 'Scale', + initialValue: 1.0, + min: 0.1, + max: 2.0, + divisions: 19, + ), + builder: (context, scale, textColor) => WrappedText( + style: (context, theme) => theme.typography.h1.copyWith( + fontSize: theme.typography.h1.fontSize! * scale, + color: textColor, + ), + child: Text(context.knobs.string(label: 'Text', initialValue: 'Next')), + ), + onPressed: () {}, + ), + ); +} + +@widgetbook.UseCase(name: 'Flat', type: ChevronButton) +Widget buildFlatChevronButtonUseCase(BuildContext context) { + return Center( + child: ChevronButton.flat( + scale: context.knobs.double.slider( + label: 'Scale', + initialValue: 1.0, + min: 0.1, + max: 2.0, + divisions: 19, + ), + builder: (context, scale, textColor) => WrappedText( + style: (context, theme) => theme.typography.h2.copyWith( + fontSize: theme.typography.h2.fontSize! * scale, + color: textColor, + ), + child: Text(context.knobs.string(label: 'Text', initialValue: 'Next')), + ), + onPressed: () {}, + ), + ); +} diff --git a/genlatte/genlatte-ui/widgetbook/lib/configuration_card_buttons_preview.dart b/genlatte/genlatte-ui/widgetbook/lib/configuration_card_buttons_preview.dart new file mode 100644 index 0000000..d947ddd --- /dev/null +++ b/genlatte/genlatte-ui/widgetbook/lib/configuration_card_buttons_preview.dart @@ -0,0 +1,50 @@ +// Copyright 2014 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:genlatte_data/models.dart'; +import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook; + +import 'package:genlatte/src/widgets/configuration_card.dart'; +import 'package:widgetbook/widgetbook.dart'; + +@widgetbook.UseCase(name: 'Buttons', type: ConfigurationCard) +Widget buildButtonsConfigurationCardUseCase(BuildContext context) { + return Center( + child: SizedBox( + width: context.knobs.double.slider( + label: 'Width', + initialValue: 600, + min: 150, + max: 2048, + ), + height: context.knobs.double.slider( + label: 'Height', + initialValue: 600, + min: 12, + max: 1536, + ), + child: Center( + child: ConfigurationCard.buttons( + flavor: context.knobs.object.segmented( + label: 'Flavor', + initialOption: .wood, + labelBuilder: (Flavor flavor) => flavor.name, + options: Flavor.values, + ), + onSelected: (String value) {}, + question: MultipleChoiceQuestion( + id: 'abc', + body: context.knobs.string( + label: 'Question', + initialValue: 'What time of day is it?', + ), + acceptableAnswers: ['Morning', 'Afternoon', 'Evening', 'Night'], + selectedValue: 'Morning', + ), + ), + ), + ), + ); +} diff --git a/genlatte/genlatte-ui/widgetbook/lib/configuration_card_slider_preview.dart b/genlatte/genlatte-ui/widgetbook/lib/configuration_card_slider_preview.dart new file mode 100644 index 0000000..3c4d48d --- /dev/null +++ b/genlatte/genlatte-ui/widgetbook/lib/configuration_card_slider_preview.dart @@ -0,0 +1,56 @@ +// Copyright 2014 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:genlatte_data/models.dart' show ZeroToOneQuestion; +import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook; + +import 'package:genlatte/src/widgets/configuration_card.dart'; +import 'package:widgetbook/widgetbook.dart'; + +@widgetbook.UseCase(name: 'Slider', type: ConfigurationCard) +Widget buildSliderConfigurationCardUseCase(BuildContext context) { + return Center( + child: SizedBox( + width: context.knobs.double.slider( + label: 'Width', + initialValue: 600, + min: 150, + max: 2048, + ), + height: context.knobs.double.slider( + label: 'Height', + initialValue: 600, + min: 12, + max: 1536, + ), + child: Center( + child: ConfigurationCard.slider( + flavor: context.knobs.object.segmented( + label: 'Flavor', + initialOption: .wood, + labelBuilder: (Flavor flavor) => flavor.name, + options: Flavor.values, + ), + onSelected: (double value) {}, + question: ZeroToOneQuestion( + id: 'abc', + body: context.knobs.string( + label: 'Question', + initialValue: 'What time of day is it?', + ), + minValueLabel: context.knobs.string( + label: 'Min Value Label', + initialValue: 'Morning', + ), + maxValueLabel: context.knobs.string( + label: 'Max Value Label', + initialValue: 'Evening', + ), + ), + ), + ), + ), + ); +} diff --git a/genlatte/genlatte-ui/widgetbook/lib/configuration_card_text_preview.dart b/genlatte/genlatte-ui/widgetbook/lib/configuration_card_text_preview.dart new file mode 100644 index 0000000..ea4a3a6 --- /dev/null +++ b/genlatte/genlatte-ui/widgetbook/lib/configuration_card_text_preview.dart @@ -0,0 +1,49 @@ +// Copyright 2014 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:genlatte_data/models.dart'; +import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook; + +import 'package:genlatte/src/widgets/configuration_card.dart'; +import 'package:widgetbook/widgetbook.dart'; + +@widgetbook.UseCase(name: 'Text', type: ConfigurationCard) +Widget buildTextConfigurationCardUseCase(BuildContext context) { + return Center( + child: SizedBox( + width: context.knobs.double.slider( + label: 'Width', + initialValue: 600, + min: 150, + max: 2048, + ), + height: context.knobs.double.slider( + label: 'Height', + initialValue: 600, + min: 12, + max: 1536, + ), + child: Center( + child: ConfigurationCard.text( + flavor: context.knobs.object.segmented( + label: 'Flavor', + initialOption: .wood, + labelBuilder: (Flavor flavor) => flavor.name, + options: Flavor.values, + ), + onSelected: (String value) {}, + question: TextQuestion( + id: 'abc', + body: context.knobs.string( + label: 'Question', + initialValue: 'What time of day is it?', + ), + helpText: 'Your name', + ), + ), + ), + ), + ); +} diff --git a/genlatte/genlatte-ui/widgetbook/lib/footer_preview.dart b/genlatte/genlatte-ui/widgetbook/lib/footer_preview.dart new file mode 100644 index 0000000..dab54fa --- /dev/null +++ b/genlatte/genlatte-ui/widgetbook/lib/footer_preview.dart @@ -0,0 +1,33 @@ +// Copyright 2014 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:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook; + +import 'package:genlatte/src/widgets/footer.dart'; +import 'package:widgetbook/widgetbook.dart'; + +@widgetbook.UseCase(name: 'Footer', type: Footer) +Widget buildFooterUseCase(BuildContext context) { + return Center( + child: ColoredBox( + color: Colors.black, + child: SizedBox( + width: context.knobs.double.slider( + label: 'Width', + initialValue: Footer.defaultWidth, + min: 150, + max: 2048, + ), + height: context.knobs.double.slider( + label: 'Height', + initialValue: Footer.defaultHeight, + min: 12, + max: 1536, + ), + child: Footer(), + ), + ), + ); +} diff --git a/genlatte/genlatte-ui/widgetbook/lib/genlatte_filled_button_preview.dart b/genlatte/genlatte-ui/widgetbook/lib/genlatte_filled_button_preview.dart new file mode 100644 index 0000000..9e80ff5 --- /dev/null +++ b/genlatte/genlatte-ui/widgetbook/lib/genlatte_filled_button_preview.dart @@ -0,0 +1,43 @@ +// Copyright 2014 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:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook; + +import 'package:genlatte/src/widgets/buttons.dart'; + +@widgetbook.UseCase(name: 'Icon & Text', type: GenLatteFilledButton) +Widget buildGenLatteFilledButtonDarkUseCase(BuildContext context) { + return Center( + child: Container( + height: 200, + width: 200, + color: Colors.black, + child: Center( + child: GenLatteFilledButton( + icon: Icons.arrow_back, + label: 'Back', + onPressed: () {}, + ), + ), + ), + ); +} + +@widgetbook.UseCase(name: 'Icon Only', type: GenLatteFilledButton) +Widget buildGenLatteFilledButtonIconOnlyUseCase(BuildContext context) { + return Center( + child: Container( + height: 200, + width: 200, + color: Colors.black, + child: Center( + child: GenLatteFilledButton( + icon: Icons.arrow_back, + onPressed: () {}, + ), + ), + ), + ); +} diff --git a/genlatte/genlatte-ui/widgetbook/lib/genlatte_outlined_button_preview.dart b/genlatte/genlatte-ui/widgetbook/lib/genlatte_outlined_button_preview.dart new file mode 100644 index 0000000..9db04d2 --- /dev/null +++ b/genlatte/genlatte-ui/widgetbook/lib/genlatte_outlined_button_preview.dart @@ -0,0 +1,35 @@ +// Copyright 2014 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:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook; + +import 'package:genlatte/src/widgets/buttons.dart'; + +@widgetbook.UseCase(name: 'Dark', type: GenLatteOutlinedButton) +Widget buildGenLatteOutlinedButtonDarkUseCase(BuildContext context) { + return Center( + child: GenLatteOutlinedButton.dark( + label: 'Dark', + onPressed: () {}, + ), + ); +} + +@widgetbook.UseCase(name: 'Light', type: GenLatteOutlinedButton) +Widget buildGenLatteOutlinedButtonLightUseCase(BuildContext context) { + return Center( + child: Container( + height: 200, + width: 200, + color: Colors.black, + child: Center( + child: GenLatteOutlinedButton.light( + label: 'Light', + onPressed: () {}, + ), + ), + ), + ); +} diff --git a/genlatte/genlatte-ui/widgetbook/lib/latte_image_preview.dart b/genlatte/genlatte-ui/widgetbook/lib/latte_image_preview.dart new file mode 100644 index 0000000..8e366a2 --- /dev/null +++ b/genlatte/genlatte-ui/widgetbook/lib/latte_image_preview.dart @@ -0,0 +1,30 @@ +// Copyright 2014 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:shadcn_flutter/shadcn_flutter.dart'; +import 'package:widgetbook/widgetbook.dart'; +import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook; + +import 'package:genlatte/src/widgets/widgets.dart'; + +@widgetbook.UseCase(name: 'Latte Image', type: LatteImageWidget) +Widget buildLatteImageUseCase(BuildContext context) { + return GenLatteScaffold( + child: Center( + child: Padding( + padding: EdgeInsets.all(60), + child: LatteImageWidget( + imageUrl: + "https://storage.googleapis.com/gcdemos-26-int-dd-latteart.firebasestorage.app/latteImages%2F0E4db620KWrbK3K5k13t%2F0.png", + dimension: context.knobs.double.slider( + label: 'Dimension', + initialValue: 200, + min: 100, + max: 500, + ), + ), + ), + ), + ); +} diff --git a/genlatte/genlatte-ui/widgetbook/lib/latte_order_card_preview.dart b/genlatte/genlatte-ui/widgetbook/lib/latte_order_card_preview.dart new file mode 100644 index 0000000..0da2ea1 --- /dev/null +++ b/genlatte/genlatte-ui/widgetbook/lib/latte_order_card_preview.dart @@ -0,0 +1,126 @@ +// Copyright 2014 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:shadcn_flutter/shadcn_flutter.dart'; +import 'package:widgetbook/widgetbook.dart'; +import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook; + +import 'package:genlatte/src/role.dart'; +import 'package:genlatte/src/screens/barista/widgets/widgets.dart'; +import 'package:genlatte_data/models.dart'; + +@widgetbook.UseCase(name: 'Latte Order Card', type: LatteOrderCard) +Widget buildLatteOrderCardUseCase(BuildContext context) { + final width = context.knobs.double.slider( + label: 'Width', + initialValue: 230, + min: 150, + max: 400, + ); + + final height = context.knobs.double.slider( + label: 'Height', + initialValue: 420, + min: 300, + max: 600, + ); + + final role = context.knobs.object.dropdown( + label: 'Role', + options: [Role.barista, Role.moderator], + initialOption: Role.barista, + ); + + final status = context.knobs.object.dropdown( + label: 'Status', + options: LatteOrderStatus.values, + initialOption: LatteOrderStatus.inProgress, + ); + + final canClaimOrders = context.knobs.boolean( + label: 'Can Claim Orders', + initialValue: true, + ); + + final isClaimed = context.knobs.boolean( + label: 'Is Claimed', + initialValue: true, + ); + + final isClaimedByMe = context.knobs.boolean( + label: 'Is Claimed By Me', + initialValue: true, + ); + + final myBarista = const Barista( + id: 'barista-me', + username: 'craig', + persona: BaristaPersona.caucasianMale, + ); + + final otherBarista = const Barista( + id: 'barista-other', + username: 'other', + persona: BaristaPersona.asianFemale, + ); + + final activeBarista = role == Role.barista ? myBarista : null; + + final claimedBy = isClaimed + ? (isClaimedByMe ? myBarista : otherBarista) + : null; + + final latte = Latte( + order: const LatteOrder( + id: 'order-123', + name: 'BigDumbIdiot', + milk: 'Whole Milk', + sweetener: 'Sugar', + happyPlace: 'A quiet beach at sunset', + ), + metadata: LatteOrderMetadata( + id: 'order-123', + orderNumber: 42, + isNameApproved: true, + isHappyPlaceApproved: true, + isImageApproved: true, + imageUrl: + 'https://storage.googleapis.com/gcdemos-26-int-dd-latteart.firebasestorage.app/latteImages%2F0E4db620KWrbK3K5k13t%2F0.png', + status: status, + baristaId: claimedBy?.id, + orderSubmittedTime: DateTime.now().subtract(const Duration(minutes: 5)), + ), + ); + + return Center( + child: SizedBox( + height: height, + width: width, + child: role == Role.barista + ? LatteOrderCard.barista( + activeBarista: activeBarista, + canClaimOrders: canClaimOrders, + claimedBy: claimedBy, + latte: latte, + onApproveAll: (_) {}, + onRejectName: (_) {}, + onRejectImage: (_) {}, + onRejectBoth: (_) {}, + onClaimPressed: (_) {}, + onCompletePressed: (_) {}, + onReprintPressed: (_) {}, + role: role, + ) + : LatteOrderCard.moderator( + claimedBy: claimedBy, + latte: latte, + onApproveAll: (_) {}, + onRejectName: (_) {}, + onRejectImage: (_) {}, + onRejectBoth: (_) {}, + onCompletePressed: (_) {}, + ), + ), + ); +} diff --git a/genlatte/genlatte-ui/widgetbook/lib/loading_dash_preview.dart b/genlatte/genlatte-ui/widgetbook/lib/loading_dash_preview.dart new file mode 100644 index 0000000..46dd127 --- /dev/null +++ b/genlatte/genlatte-ui/widgetbook/lib/loading_dash_preview.dart @@ -0,0 +1,28 @@ +// Copyright 2014 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:shadcn_flutter/shadcn_flutter.dart'; +import 'package:widgetbook/widgetbook.dart'; +import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook; + +import 'package:genlatte/src/widgets/widgets.dart'; + +@widgetbook.UseCase(name: 'Loading Dash', type: LoadingDash) +Widget buildLoadingDashUseCase(BuildContext context) { + return GenLatteScaffold( + child: Center( + child: Padding( + padding: EdgeInsets.all( + context.knobs.double.slider( + label: 'Padding', + initialValue: 20, + min: 20, + max: 320, + ), + ), + child: LoadingDash(), + ), + ), + ); +} diff --git a/genlatte/genlatte-ui/widgetbook/lib/main.dart b/genlatte/genlatte-ui/widgetbook/lib/main.dart new file mode 100644 index 0000000..bd5868f --- /dev/null +++ b/genlatte/genlatte-ui/widgetbook/lib/main.dart @@ -0,0 +1,38 @@ +// Copyright 2014 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:shadcn_flutter/shadcn_flutter.dart'; +import 'package:widgetbook/widgetbook.dart'; +import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook; + +import 'package:genlatte/src/screens/app/theme.dart'; + +// This file does not exist yet, +// it will be generated in the next step +import 'main.directories.g.dart'; + +void main() { + runApp(const WidgetbookApp()); +} + +@widgetbook.App() +class WidgetbookApp extends StatelessWidget { + const WidgetbookApp({super.key}); + + @override + Widget build(BuildContext context) { + return Widgetbook( + appBuilder: (context, child) { + return ShadcnApp( + debugShowCheckedModeBanner: false, + theme: getThemeForOrientation(context), + home: child, + ); + }, + // The [directories] variable does not exist yet, + // it will be generated in the next step + directories: directories, + ); + } +} diff --git a/genlatte/genlatte-ui/widgetbook/lib/main.directories.g.dart b/genlatte/genlatte-ui/widgetbook/lib/main.directories.g.dart new file mode 100644 index 0000000..305e1d5 --- /dev/null +++ b/genlatte/genlatte-ui/widgetbook/lib/main.directories.g.dart @@ -0,0 +1,222 @@ +// Copyright 2014 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. + +// dart format width=80 +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_import, prefer_relative_imports, directives_ordering + +// GENERATED CODE - DO NOT MODIFY BY HAND + +// ************************************************************************** +// AppGenerator +// ************************************************************************** + +// ignore_for_file: no_leading_underscores_for_library_prefixes +import 'package:widgetbook/widgetbook.dart' as _widgetbook; +import 'package:widgetbook_workspace/chevon_button_preview.dart' + as _widgetbook_workspace_chevon_button_preview; +import 'package:widgetbook_workspace/configuration_card_buttons_preview.dart' + as _widgetbook_workspace_configuration_card_buttons_preview; +import 'package:widgetbook_workspace/configuration_card_slider_preview.dart' + as _widgetbook_workspace_configuration_card_slider_preview; +import 'package:widgetbook_workspace/configuration_card_text_preview.dart' + as _widgetbook_workspace_configuration_card_text_preview; +import 'package:widgetbook_workspace/footer_preview.dart' + as _widgetbook_workspace_footer_preview; +import 'package:widgetbook_workspace/genlatte_filled_button_preview.dart' + as _widgetbook_workspace_genlatte_filled_button_preview; +import 'package:widgetbook_workspace/genlatte_outlined_button_preview.dart' + as _widgetbook_workspace_genlatte_outlined_button_preview; +import 'package:widgetbook_workspace/latte_image_preview.dart' + as _widgetbook_workspace_latte_image_preview; +import 'package:widgetbook_workspace/latte_order_card_preview.dart' + as _widgetbook_workspace_latte_order_card_preview; +import 'package:widgetbook_workspace/loading_dash_preview.dart' + as _widgetbook_workspace_loading_dash_preview; +import 'package:widgetbook_workspace/outlined_chevron_button_preview.dart' + as _widgetbook_workspace_outlined_chevron_button_preview; +import 'package:widgetbook_workspace/responsive_chevron_button_preview.dart' + as _widgetbook_workspace_responsive_chevron_button_preview; +import 'package:widgetbook_workspace/segmented_progress_preview.dart' + as _widgetbook_workspace_segmented_progress_preview; +import 'package:widgetbook_workspace/squish_widget_preview.dart' + as _widgetbook_workspace_squish_widget_preview; + +final directories = <_widgetbook.WidgetbookNode>[ + _widgetbook.WidgetbookFolder( + name: 'screens', + children: [ + _widgetbook.WidgetbookFolder( + name: 'barista', + children: [ + _widgetbook.WidgetbookFolder( + name: 'widgets', + children: [ + _widgetbook.WidgetbookComponent( + name: 'LatteOrderCard', + useCases: [ + _widgetbook.WidgetbookUseCase( + name: 'Latte Order Card', + builder: _widgetbook_workspace_latte_order_card_preview + .buildLatteOrderCardUseCase, + ), + ], + ), + ], + ), + ], + ), + _widgetbook.WidgetbookFolder( + name: 'kiosk', + children: [ + _widgetbook.WidgetbookFolder( + name: 'widgets', + children: [ + _widgetbook.WidgetbookComponent( + name: 'SegmentedProgress', + useCases: [ + _widgetbook.WidgetbookUseCase( + name: 'Default', + builder: _widgetbook_workspace_segmented_progress_preview + .buildSegmentedProgressUseCase, + ), + ], + ), + ], + ), + ], + ), + _widgetbook.WidgetbookFolder( + name: 'recent_orders', + children: [ + _widgetbook.WidgetbookFolder( + name: 'widgets', + children: [ + _widgetbook.WidgetbookComponent( + name: 'SquishedWidget', + useCases: [ + _widgetbook.WidgetbookUseCase( + name: 'Icon & Text', + builder: _widgetbook_workspace_squish_widget_preview + .squishWidgetPreviewUseCase, + ), + ], + ), + ], + ), + ], + ), + ], + ), + _widgetbook.WidgetbookFolder( + name: 'widgets', + children: [ + _widgetbook.WidgetbookComponent( + name: 'ChevronButton', + useCases: [ + _widgetbook.WidgetbookUseCase( + name: 'Flat', + builder: _widgetbook_workspace_chevon_button_preview + .buildFlatChevronButtonUseCase, + ), + _widgetbook.WidgetbookUseCase( + name: 'Outlined', + builder: _widgetbook_workspace_outlined_chevron_button_preview + .buildChevronButtonUseCase, + ), + _widgetbook.WidgetbookUseCase( + name: 'Responsive', + builder: _widgetbook_workspace_responsive_chevron_button_preview + .buildChevronButtonUseCase, + ), + _widgetbook.WidgetbookUseCase( + name: 'Vertical', + builder: _widgetbook_workspace_chevon_button_preview + .buildVerticalChevronButtonUseCase, + ), + ], + ), + _widgetbook.WidgetbookComponent( + name: 'ConfigurationCard', + useCases: [ + _widgetbook.WidgetbookUseCase( + name: 'Buttons', + builder: _widgetbook_workspace_configuration_card_buttons_preview + .buildButtonsConfigurationCardUseCase, + ), + _widgetbook.WidgetbookUseCase( + name: 'Slider', + builder: _widgetbook_workspace_configuration_card_slider_preview + .buildSliderConfigurationCardUseCase, + ), + _widgetbook.WidgetbookUseCase( + name: 'Text', + builder: _widgetbook_workspace_configuration_card_text_preview + .buildTextConfigurationCardUseCase, + ), + ], + ), + _widgetbook.WidgetbookComponent( + name: 'Footer', + useCases: [ + _widgetbook.WidgetbookUseCase( + name: 'Footer', + builder: _widgetbook_workspace_footer_preview.buildFooterUseCase, + ), + ], + ), + _widgetbook.WidgetbookComponent( + name: 'GenLatteFilledButton', + useCases: [ + _widgetbook.WidgetbookUseCase( + name: 'Icon & Text', + builder: _widgetbook_workspace_genlatte_filled_button_preview + .buildGenLatteFilledButtonDarkUseCase, + ), + _widgetbook.WidgetbookUseCase( + name: 'Icon Only', + builder: _widgetbook_workspace_genlatte_filled_button_preview + .buildGenLatteFilledButtonIconOnlyUseCase, + ), + ], + ), + _widgetbook.WidgetbookComponent( + name: 'GenLatteOutlinedButton', + useCases: [ + _widgetbook.WidgetbookUseCase( + name: 'Dark', + builder: _widgetbook_workspace_genlatte_outlined_button_preview + .buildGenLatteOutlinedButtonDarkUseCase, + ), + _widgetbook.WidgetbookUseCase( + name: 'Light', + builder: _widgetbook_workspace_genlatte_outlined_button_preview + .buildGenLatteOutlinedButtonLightUseCase, + ), + ], + ), + _widgetbook.WidgetbookComponent( + name: 'LatteImageWidget', + useCases: [ + _widgetbook.WidgetbookUseCase( + name: 'Latte Image', + builder: _widgetbook_workspace_latte_image_preview + .buildLatteImageUseCase, + ), + ], + ), + _widgetbook.WidgetbookComponent( + name: 'LoadingDash', + useCases: [ + _widgetbook.WidgetbookUseCase( + name: 'Loading Dash', + builder: _widgetbook_workspace_loading_dash_preview + .buildLoadingDashUseCase, + ), + ], + ), + ], + ), +]; diff --git a/genlatte/genlatte-ui/widgetbook/lib/outlined_chevron_button_preview.dart b/genlatte/genlatte-ui/widgetbook/lib/outlined_chevron_button_preview.dart new file mode 100644 index 0000000..e6f4940 --- /dev/null +++ b/genlatte/genlatte-ui/widgetbook/lib/outlined_chevron_button_preview.dart @@ -0,0 +1,44 @@ +// Copyright 2014 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:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook; + +import 'package:genlatte/src/widgets/widgets.dart'; +import 'package:widgetbook/widgetbook.dart'; + +@widgetbook.UseCase( + name: 'Outlined', + type: ChevronButton, +) +Widget buildChevronButtonUseCase(BuildContext context) { + return Center( + child: ColoredBox( + color: Colors.black, + child: SizedBox( + width: context.knobs.double.slider( + label: 'Width', + initialValue: 300, + min: 150, + max: 2048, + ), + height: context.knobs.double.slider( + label: 'Height', + initialValue: 500, + min: 12, + max: 1536, + ), + child: LayoutProvider( + child: ResponsiveChevronButton( + onPressed: () {}, + color: Colors.transparent, + borderColor: const Color(0xFFFBBC04), + textColor: const Color(0xFFFBBC04), + text: context.knobs.string(label: 'Text', initialValue: 'Next'), + ), + ), + ), + ), + ); +} diff --git a/genlatte/genlatte-ui/widgetbook/lib/responsive_chevron_button_preview.dart b/genlatte/genlatte-ui/widgetbook/lib/responsive_chevron_button_preview.dart new file mode 100644 index 0000000..a161a77 --- /dev/null +++ b/genlatte/genlatte-ui/widgetbook/lib/responsive_chevron_button_preview.dart @@ -0,0 +1,41 @@ +// Copyright 2014 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:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook; + +import 'package:genlatte/src/widgets/widgets.dart'; +import 'package:widgetbook/widgetbook.dart'; + +@widgetbook.UseCase( + name: 'Responsive', + type: ChevronButton, +) +Widget buildChevronButtonUseCase(BuildContext context) { + return Center( + child: ColoredBox( + color: Colors.black, + child: SizedBox( + width: context.knobs.double.slider( + label: 'Width', + initialValue: 300, + min: 150, + max: 2048, + ), + height: context.knobs.double.slider( + label: 'Height', + initialValue: 500, + min: 12, + max: 1536, + ), + child: LayoutProvider( + child: ResponsiveChevronButton( + onPressed: () {}, + text: context.knobs.string(label: 'Text', initialValue: 'Next'), + ), + ), + ), + ), + ); +} diff --git a/genlatte/genlatte-ui/widgetbook/lib/segmented_progress_preview.dart b/genlatte/genlatte-ui/widgetbook/lib/segmented_progress_preview.dart new file mode 100644 index 0000000..a980b14 --- /dev/null +++ b/genlatte/genlatte-ui/widgetbook/lib/segmented_progress_preview.dart @@ -0,0 +1,25 @@ +// Copyright 2014 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:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook; + +import 'package:genlatte/src/screens/kiosk/widgets/segmented_progress.dart'; +import 'package:widgetbook/widgetbook.dart'; + +@widgetbook.UseCase(name: 'Default', type: SegmentedProgress) +Widget buildSegmentedProgressUseCase(BuildContext context) { + return Center( + child: SegmentedProgress( + currentStep: context.knobs.int.input( + label: 'Current Step', + initialValue: 0, + ), + totalSteps: context.knobs.int.input( + label: 'Total Steps', + initialValue: 4, + ), + ), + ); +} diff --git a/genlatte/genlatte-ui/widgetbook/lib/squish_widget_preview.dart b/genlatte/genlatte-ui/widgetbook/lib/squish_widget_preview.dart new file mode 100644 index 0000000..dd1cdfb --- /dev/null +++ b/genlatte/genlatte-ui/widgetbook/lib/squish_widget_preview.dart @@ -0,0 +1,50 @@ +// Copyright 2014 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:math' show pi; + +import 'package:flutter/material.dart'; +import 'package:genlatte/src/screens/recent_orders/widgets/widgets.dart'; +import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook; +import 'package:widgetbook/widgetbook.dart'; + +@widgetbook.UseCase(name: 'Icon & Text', type: SquishedWidget) +Widget squishWidgetPreviewUseCase(BuildContext context) { + return Center( + child: SquishedWidget( + direction: context.knobs.double.slider( + label: 'Direction', + initialValue: 0, + min: -pi, + max: pi, + ), + effectStrength: context.knobs.double.slider( + label: 'Effect Strength', + initialValue: 0, + min: -1, + max: 10, + ), + child: SizedBox( + width: 200, + height: 200, + child: Stack( + children: [ + Positioned( + left: 50, + top: 50, + child: Container( + width: 100, + height: 100, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(100), + color: Colors.red, + ), + ), + ), + ], + ), + ), + ), + ); +} diff --git a/genlatte/genlatte-ui/widgetbook/pubspec.yaml b/genlatte/genlatte-ui/widgetbook/pubspec.yaml new file mode 100644 index 0000000..4774723 --- /dev/null +++ b/genlatte/genlatte-ui/widgetbook/pubspec.yaml @@ -0,0 +1,45 @@ +name: widgetbook_workspace +description: GenLatte widget catalog / browser. +publish_to: 'none' +version: 0.1.0+1 + +environment: + sdk: ^3.10.0 + +dependencies: + data_layer: 0.0.5 + data_layer_hive: ^0.0.2-rc.1 + flutter: + sdk: flutter + flutter_animate: ^4.5.2 + flutter_shaders: ^0.1.3 + genlatte: + path: ../ + genlatte_data: + path: ../../genlatte-data + shadcn_flutter: ^0.0.52 + video_player: ^2.11.1 + widgetbook: ^3.20.2 + widgetbook_annotation: ^3.9.0 + +dev_dependencies: + build_runner: ^2.10.5 + flutter_lints: ^6.0.0 + flutter_test: + sdk: flutter + widgetbook_generator: ^3.20.1 + +flutter: + uses-material-design: true + + assets: + - assets/firebase-logo.png + - assets/flutter-logo.png + - assets/gemini-logo.png + - assets/latte-background.png + + - assets/videos/Dash_Loading_1080px.webm + - assets/videos/Dash_Loading_1080px_H.265.mov + + shaders: + - assets/shaders/squish.glsl diff --git a/genlatte/genlatte-ui/widgetbook/web/CONTEXT.md b/genlatte/genlatte-ui/widgetbook/web/CONTEXT.md new file mode 100644 index 0000000..8be67cb --- /dev/null +++ b/genlatte/genlatte-ui/widgetbook/web/CONTEXT.md @@ -0,0 +1,14 @@ +# Widgetbook Web Entry + +**Purpose:** +Coordinates the web-based delivery of the Widgetbook component library. + +**Detailed File Overviews:** +- `index.html`: Web entry point. +- `manifest.json`: Web manifest for PWA support. + +**Subdirectories:** +- `icons/`: PWA icon assets. + +**Dependencies/Relationships:** +- Serves the `genlatte-ui` component previews to a browser. diff --git a/genlatte/genlatte-ui/widgetbook/web/favicon.png b/genlatte/genlatte-ui/widgetbook/web/favicon.png new file mode 100644 index 0000000..8aaa46a Binary files /dev/null and b/genlatte/genlatte-ui/widgetbook/web/favicon.png differ diff --git a/genlatte/genlatte-ui/widgetbook/web/icons/CONTEXT.md b/genlatte/genlatte-ui/widgetbook/web/icons/CONTEXT.md new file mode 100644 index 0000000..836a9b3 --- /dev/null +++ b/genlatte/genlatte-ui/widgetbook/web/icons/CONTEXT.md @@ -0,0 +1,10 @@ +# Widgetbook Web Icons + +**Purpose:** +Provides icon assets specific to the Widgetbook (Storybook) web build. + +**Detailed File Overviews:** +- `Icon-192.png` / `Icon-512.png`: Standard and maskable icons for the Widgetbook PWA. + +**Dependencies/Relationships:** +- Used by the `genlatte-ui/widgetbook/web` entry point. diff --git a/genlatte/genlatte-ui/widgetbook/web/icons/Icon-192.png b/genlatte/genlatte-ui/widgetbook/web/icons/Icon-192.png new file mode 100644 index 0000000..b749bfe Binary files /dev/null and b/genlatte/genlatte-ui/widgetbook/web/icons/Icon-192.png differ diff --git a/genlatte/genlatte-ui/widgetbook/web/icons/Icon-512.png b/genlatte/genlatte-ui/widgetbook/web/icons/Icon-512.png new file mode 100644 index 0000000..88cfd48 Binary files /dev/null and b/genlatte/genlatte-ui/widgetbook/web/icons/Icon-512.png differ diff --git a/genlatte/genlatte-ui/widgetbook/web/icons/Icon-maskable-192.png b/genlatte/genlatte-ui/widgetbook/web/icons/Icon-maskable-192.png new file mode 100644 index 0000000..eb9b4d7 Binary files /dev/null and b/genlatte/genlatte-ui/widgetbook/web/icons/Icon-maskable-192.png differ diff --git a/genlatte/genlatte-ui/widgetbook/web/icons/Icon-maskable-512.png b/genlatte/genlatte-ui/widgetbook/web/icons/Icon-maskable-512.png new file mode 100644 index 0000000..d69c566 Binary files /dev/null and b/genlatte/genlatte-ui/widgetbook/web/icons/Icon-maskable-512.png differ diff --git a/genlatte/genlatte-ui/widgetbook/web/index.html b/genlatte/genlatte-ui/widgetbook/web/index.html new file mode 100644 index 0000000..2e6b855 --- /dev/null +++ b/genlatte/genlatte-ui/widgetbook/web/index.html @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + widgetbook + + + + + + + diff --git a/genlatte/genlatte-ui/widgetbook/web/manifest.json b/genlatte/genlatte-ui/widgetbook/web/manifest.json new file mode 100644 index 0000000..6bd71b6 --- /dev/null +++ b/genlatte/genlatte-ui/widgetbook/web/manifest.json @@ -0,0 +1,35 @@ +{ + "name": "widgetbook", + "short_name": "widgetbook", + "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/genui_workshop/.gitignore b/genui_workshop/.gitignore new file mode 100644 index 0000000..475d165 --- /dev/null +++ b/genui_workshop/.gitignore @@ -0,0 +1,32 @@ +# See https://www.dartlang.org/guides/libraries/private-files + +# Files and directories created by pub +.dart_tool/ +.packages +build/ +# If you're building an application, you may want to check-in your pubspec.lock +pubspec.lock + +# Directory created by dartdoc +# If you don't generate documentation locally you can remove this line. +doc/api/ + +# dotenv environment variables file +.env* + +# Avoid committing generated Javascript files: +*.dart.js +# Produced by the --dump-info flag. +*.info.json +# When generated by dart2js. Don't specify *.js if your +# project includes source files written in JavaScript. +*.js +*.js_ +*.js.deps +*.js.map + +.flutter-plugins +.flutter-plugins-dependencies +firebase.json +lib/firebase_options.dart +.DS_Store diff --git a/genui_workshop/.metadata b/genui_workshop/.metadata new file mode 100644 index 0000000..9bf27ae --- /dev/null +++ b/genui_workshop/.metadata @@ -0,0 +1,45 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "437ec2cbd40a0a28b6f7528e49985ed14580ff0e" + channel: "master" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: 437ec2cbd40a0a28b6f7528e49985ed14580ff0e + base_revision: 437ec2cbd40a0a28b6f7528e49985ed14580ff0e + - platform: android + create_revision: 437ec2cbd40a0a28b6f7528e49985ed14580ff0e + base_revision: 437ec2cbd40a0a28b6f7528e49985ed14580ff0e + - platform: ios + create_revision: 437ec2cbd40a0a28b6f7528e49985ed14580ff0e + base_revision: 437ec2cbd40a0a28b6f7528e49985ed14580ff0e + - platform: linux + create_revision: 437ec2cbd40a0a28b6f7528e49985ed14580ff0e + base_revision: 437ec2cbd40a0a28b6f7528e49985ed14580ff0e + - platform: macos + create_revision: 437ec2cbd40a0a28b6f7528e49985ed14580ff0e + base_revision: 437ec2cbd40a0a28b6f7528e49985ed14580ff0e + - platform: web + create_revision: 437ec2cbd40a0a28b6f7528e49985ed14580ff0e + base_revision: 437ec2cbd40a0a28b6f7528e49985ed14580ff0e + - platform: windows + create_revision: 437ec2cbd40a0a28b6f7528e49985ed14580ff0e + base_revision: 437ec2cbd40a0a28b6f7528e49985ed14580ff0e + + # 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/genui_workshop/README.md b/genui_workshop/README.md new file mode 100644 index 0000000..666824b --- /dev/null +++ b/genui_workshop/README.md @@ -0,0 +1,871 @@ +# genui_workshop + +## Step 1: Set up Firebase + +### Create a Firebase project +1. Sign into the [Firebase console](https://console.firebase.google.com/) + using your Google Account. + +1. Click the button to create a new project, and then enter a project name + (for example, `GenUI Workshop`).
+ +1. Click **Continue**. + +1. If prompted, review and accept the Firebase terms, and then click + **Continue**. + +1. Click **Create project**, wait for your project to provision, and then click + **Continue**. + +### Enable Firebase AI Logic + +To enable Firebase AI Logic in your Firebase project: + +1. In the Firebase console, go to **AI Services** > + [**AI Logic**](https://console.firebase.google.com/project/_/ailogic/). + +1. Click **Get started**. + +1. Choose to use the _Gemini Developer API_ by clicking + **Get started with this API**. + +1. Click **Enable API** and confirm. + +## Step 2: Activate Cloud Shell + +Cloud Shell space is persistent between projects so make sure you are creating things in proper project directories. + +## Step 3: Creating the Flutter project + +Cloud shell needs to link to the stored version of Flutter so we need the following command before running `flutter`. + +```shell +git config --global --add safe.directory /google/flutter +``` + +We'll be starting with an empty project and adding code as we go. Warning that this might take a little while on Cloud Shell. + +```shell +flutter create --empty genui_workshop +``` + +Next we can attach our local code to the previously created project with `flutterfire`. We only need the web target. + +```shell +dart pub global activate flutterfire_cli +export PATH="$PATH":"$HOME/.pub-cache/bin" + +cd genui_workshop +flutterfire configure +``` +Select the project you just created; +deselect all but web + +```shell +flutter pub add genui firebase_core firebase_ai json_schema_builder +``` + +Let's run the app. + +Cloud shell doesn't allow `flutter run -d chrome` by default so we will test it by building the web target and starting a server. +```shell +flutter build web +python -m http.server 8080 --directory build/web +``` + +## Step 4: Setup GenUI scaffolding. + +Create `lib/genui_utils.dart` +```dart +sealed class ConversationItem {} + +class TextItem extends ConversationItem { + final String text; + final bool isUser; + TextItem({required this.text, this.isUser = false}); +} +``` + +Create `lib/message_bubble.dart`. +```dart +import 'package:flutter/material.dart'; + +class MessageBubble extends StatelessWidget { + final String text; + final bool isUser; + + const MessageBubble({super.key, required this.text, required this.isUser}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + final bubbleColor = isUser + ? colorScheme.primary + : colorScheme.surfaceContainerHighest; + + final textColor = isUser + ? colorScheme.onPrimary + : colorScheme.onSurfaceVariant; + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6.0, horizontal: 8.0), + child: Column( + crossAxisAlignment: isUser + ? CrossAxisAlignment.end + : CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: isUser + ? MainAxisAlignment.end + : MainAxisAlignment.start, + children: [ + Flexible( + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 16.0, + vertical: 12.0, + ), + decoration: BoxDecoration( + color: bubbleColor, + borderRadius: BorderRadius.only( + topLeft: const Radius.circular(20), + topRight: const Radius.circular(20), + bottomLeft: Radius.circular(isUser ? 20 : 0), + bottomRight: Radius.circular(isUser ? 0 : 20), + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withAlpha(20), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + gradient: isUser + ? LinearGradient( + colors: [ + colorScheme.primary, + colorScheme.primary.withAlpha(200), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ) + : null, + ), + child: Text( + text, + style: theme.textTheme.bodyLarge?.copyWith( + color: textColor, + height: 1.3, + ), + ), + ), + ), + ], + ), + const SizedBox(height: 2), + ], + ), + ); + } +} + +``` + +Paste the following to `main.dart` +```dart +import 'package:flutter/material.dart'; +import 'package:firebase_core/firebase_core.dart'; +import 'package:firebase_ai/firebase_ai.dart'; +import 'package:genui_workshop/message_bubble.dart'; +import 'package:genui_workshop/genui_utils.dart'; +import 'firebase_options.dart'; + +Future main() async { + WidgetsFlutterBinding.ensureInitialized(); + await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform); + runApp(const MyApp()); +} + +class MyApp extends StatelessWidget { + const MyApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'Weather Today', + theme: ThemeData( + colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue), + ), + home: const MyHomePage(), + ); + } +} + +class MyHomePage extends StatefulWidget { + const MyHomePage({super.key}); + + @override + State createState() => _MyHomePageState(); +} + +class _MyHomePageState extends State { + final List _items = []; + final _textController = TextEditingController(); + final _scrollController = ScrollController(); + late final ChatSession _chatSession; + + @override + void initState() { + super.initState(); + final model = FirebaseAI.googleAI().generativeModel( + model: 'gemini-3.1-flash-lite', + ); + _chatSession = model.startChat(); + _chatSession.sendMessage(Content.text(systemInstruction)); + } + + void _scrollToBottom() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_scrollController.hasClients) { + _scrollController.animateTo( + _scrollController.position.maxScrollExtent, + duration: const Duration(milliseconds: 300), + curve: Curves.easeOut, + ); + } + }); + } + + @override + void dispose() { + _textController.dispose(); + _scrollController.dispose(); + super.dispose(); + } + + Future _addMessage() async { + final text = _textController.text; + + if (text.trim().isEmpty) { + return; + } + _textController.clear(); + + setState(() { + _items.add(TextItem(text: text, isUser: true)); + }); + + _scrollToBottom(); + + final response = await _chatSession.sendMessage(Content.text(text)); + + if (response.text?.isNotEmpty ?? false) { + setState(() { + _items.add(TextItem(text: response.text!, isUser: false)); + }); + _scrollToBottom(); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + backgroundColor: Theme.of(context).colorScheme.inversePrimary, + title: const Text('Weather'), + ), + body: Column( + children: [ + Expanded( + child: ListView( + controller: _scrollController, + padding: const EdgeInsets.all(16), + children: [ + for (final item in _items) + switch (item) { + TextItem() => MessageBubble( + text: item.text, + isUser: item.isUser, + ), + }, + ], + ), + ), + SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Row( + children: [ + Expanded( + child: TextField( + controller: _textController, + onSubmitted: (_) => _addMessage(), + decoration: const InputDecoration( + hintText: 'Enter a message', + ), + ), + ), + const SizedBox(width: 8), + ElevatedButton( + onPressed: _addMessage, + child: const Text('Send'), + ), + ], + ), + ), + ), + ], + ), + ); + } +} +``` + + +Add the system instruction to `genui_utils.dart`. +```dart + const systemInstruction = ''' + ## PERSONA + You are a meteorologist. + + ## GOAL + Work with me to produce of weather forecasts. + + ## RULES + + Do not offer opinions unless I ask for them. + + ## PROCESS + ### Planning + * Ask me for a location to check the weather. + * Follow up and ask for a date if not provided. + * Synthesize a list of weather forecasts from the provided information. + * Where available, you will use tool calls to retreive the info (not implemented yet) + * Advise if you are pulling the data from a real source or making it up. + * Ask clarifying questions if you need to. + * Respond to my suggestions for changes to date or location, if I have any. +'''; +``` + +Run the app. +It runs and responds with weather information, but no generative UI yet. + +## Integrate GenUI package +Add the following to `genui_utils.dart` +```dart +class SurfaceItem extends ConversationItem { + final String surfaceId; + SurfaceItem({required this.surfaceId}); +} + +``` + +In `main.dart`, add a case for the `SurfaceItem` type to the ListView `build` method: +```dart +Expanded( + child: ListView( + controller: _scrollController, + padding: const EdgeInsets.all(16), + children: [ + for (final item in _items) + switch (item) { + TextItem() => MessageBubble( + text: item.text, + isUser: item.isUser, + ), + // New! + SurfaceItem() => Surface( + surfaceContext: _controller.contextFor( + item.surfaceId, + ), + ), + }, + ], + ), +), +``` + + +At the top of lib/main.dart, import the genui library: +```dart +import 'package:genui/genui.dart' hide TextPart; +import 'package:genui/genui.dart' as genui; +``` + + +Add the following lifecycle controllers and adapters to `main.dart` +```dart +class _MyHomePageState extends State { + // ... existing members + late final ChatSession _chatSession; + + // Add GenUI controllers + late final SurfaceController _controller; + late final A2uiTransportAdapter _transport; + late final Conversation _conversation; + late final Catalog catalog; +``` + +Run the app. + +Update `_addMessage` and `sendAndReceive` like in code lab. + +```dart + Future _addMessage() async { + final text = _textController.text; + + if (text.trim().isEmpty) { + return; + } + + _textController.clear(); + + setState(() { + _items.add(TextItem(text: text, isUser: true)); + }); + + _scrollToBottom(); + + // Send the user's input through GenUI instead of directly to Firebase. + await _conversation.sendRequest(ChatMessage.user(text)); + } +``` + +```dart + Future _sendAndReceive(ChatMessage msg) async { + final buffer = StringBuffer(); + + // Reconstruct the message part fragments + for (final part in msg.parts) { + if (part.isUiInteractionPart) { + buffer.write(part.asUiInteractionPart!.interaction); + print(part.asUiInteractionPart!.interaction); + } else if (part is genui.TextPart) { + buffer.write(part.text); + } + } + + if (buffer.isEmpty) { + return; + } + + final text = buffer.toString(); + + // Send the string to Firebase AI Logic. + final response = await _chatSession.sendMessage(Content.text(text)); + + if (response.text?.isNotEmpty ?? false) { + // Feed the response back into GenUI's transportation layer + _transport.addChunk(response.text!); + } + } +``` + +### Wire up A2UI + +Add to the end of initState. +```dart + // remove this line + // _chatSession.sendMessage(Content.text(systemInstruction)); + + // Initialize the GenUI Catalog. + // The genui package provides a default set of primitive widgets (like text + // and basic buttons) out of the box using this class. + catalog = BasicCatalogItems.asCatalog().copyWith( +// newItems: [weatherInput, weatherCard], + ); + + // Create a SurfaceController to manage the state of generated surfaces. + _controller = SurfaceController(catalogs: [catalog]); + + // Create a transport adapter that will process messages to and from the + // agent, looking for A2UI messages. + _transport = A2uiTransportAdapter(onSend: _sendAndReceive); + + // Link the transport and SurfaceController together in a Conversation, + // which provides your app a unified API for interacting with the agent. + _conversation = Conversation( + controller: _controller, + transport: _transport, + ); + + // Listen to GenUI stream events to update the UI + _conversation.events.listen((event) { + setState(() { + switch (event) { + case ConversationSurfaceAdded added: + _items.add(SurfaceItem(surfaceId: added.surfaceId)); + _scrollToBottom(); + case ConversationSurfaceRemoved removed: + _items.removeWhere( + (item) => + item is SurfaceItem && item.surfaceId == removed.surfaceId, + ); + case ConversationContentReceived content: + _items.add(TextItem(text: content.text, isUser: false)); + _scrollToBottom(); + case ConversationError error: + debugPrint('GenUI Error: ${error.error}'); + default: + } + }); + }); + + // Create the system prompt for the agent, which will include this app's + // system instruction as well as the schema for the catalog. + final promptBuilder = PromptBuilder.chat( + catalog: catalog, + systemPromptFragments: [systemInstruction], + ); + + // Send the prompt into the Conversation, which will subsequently route it + // to Firebase using the transport mechanism. + _conversation.sendRequest( + ChatMessage.system(promptBuilder.systemPromptJoined()), + ); +``` + +We could explore doing some progress indicator UI here or not. + +## Make weather input widget +The components it picks is random and can't be submitted sometimes + +Create a `lib/catalog` directory. One file per item with the schema included. + +Add the following to `lib/catalog/weather_input.dart` +```dart +import 'package:flutter/material.dart'; +import 'package:genui/genui.dart'; +import 'package:json_schema_builder/json_schema_builder.dart'; + +final simpleWeatherSchema = S.object( + properties: { + 'location': S.string(description: 'The location to check the weather.'), + 'date': S.string(description: 'The date to check the weather.'), + }, +); + +// Example raw action +//{version: v0.9, action: {name: submit_weather_request, sourceComponentId: submitButton, +//timestamp: 2026-05-26T22:10:19.087, context: {}, surfaceId: weather_input_surface}} +final weatherInput = CatalogItem( + name: 'WeatherInput', + dataSchema: simpleWeatherSchema, + widgetBuilder: (itemContext) { + final json = itemContext.data as Map; + final data = SimpleWeatherData.fromJson(json); + return WeatherInput( + data: data, + onFetchRequest: (loc, date) async { + final JsonMap resolvedContext = await resolveContext( + itemContext.dataContext, + {'location': loc, 'date': date.toString()}, + ); + itemContext.dispatchEvent( + UserActionEvent( + name: 'submit_weather_request', + sourceComponentId: 'submitButton', + timestamp: DateTime.now(), + context: resolvedContext + ), + ); + }, + ); + }, +); + +class SimpleWeatherData { + String location; + DateTime date; + + SimpleWeatherData({required this.location, required this.date}); + + factory SimpleWeatherData.defaultValues() { + return SimpleWeatherData(location: 'Baltimore', date: DateTime.now()); + } + + factory SimpleWeatherData.fromJson(Map json) { + if (json.isNotEmpty) { + return SimpleWeatherData( + location: json['location'] as String, + date: DateTime.parse(json['date'] as String), + ); + } else { + + return SimpleWeatherData(location: 'Baltimore', date: DateTime.now()); + } + } +} + +class WeatherInput extends StatefulWidget { + final SimpleWeatherData data; + final void Function(String, DateTime) onFetchRequest; + + const WeatherInput({ + super.key, + required this.data, + required this.onFetchRequest, + }); + + @override + State createState() => _WeatherInputState(); +} + +class _WeatherInputState extends State { + late TextEditingController _controller; + DateTime? selectedDate = DateTime.now(); + + Future _selectDate() async { + final DateTime? pickedDate = await showDatePicker( + context: context, + initialDate: DateTime.now(), + firstDate: DateTime.now().copyWith(month: 1, day: 1), + lastDate: DateTime.now().copyWith(month: 12, day: 31), + ); + + setState(() { + selectedDate = pickedDate; + }); + } + + @override + void initState() { + // TODO: implement initState + super.initState(); + _controller = TextEditingController(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + + @override + Widget build(BuildContext context) { + return Container( + width: 320, + padding: const EdgeInsets.all(16), + child: Card( + elevation: 4, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + child: Padding( + padding: const EdgeInsets.all(24.0), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text("Location", style: TextStyle(fontWeight: FontWeight.bold) ), + const SizedBox(height: 8), + TextField(decoration: InputDecoration(border: OutlineInputBorder()), controller: _controller,), + const SizedBox(height: 20), + SizedBox( + width: double.infinity, + height: 50, + child: OutlinedButton( + style: OutlinedButton.styleFrom( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(25)), + ), + onPressed: () { + + widget.onFetchRequest(_controller.text, selectedDate!); + }, + child: const Text("Get Forecast", style: TextStyle(color: Colors.black, fontSize: 16, fontWeight: FontWeight.bold)), + ), + ), + ], + ), + ), + ), + ); + } +} +``` + +Add `weatherInput` as a new item in the basic catalog by importing it and adding it to `newItems` in `lib/main.dart`: + +```dart +import 'package:genui_workshop/catalog/weather_input.dart'; +// ... + catalog = BasicCatalogItems.asCatalog().copyWith( + newItems: [weatherInput], + ); +``` + +Change the system prompt to point it to the catalog item. +Add this to end of the system instruction prompt: + +```dart + + ## USER INTERFACE + * To request the location to retreive weather, create an instance of the WeatherInput + catalog item. +``` + +Create a file called `lib/catalog/weather_card.dart` + +```dart +import 'package:flutter/material.dart'; +import 'package:genui_workshop/genui_utils.dart'; +import 'package:genui/genui.dart'; +import 'package:json_schema_builder/json_schema_builder.dart'; +import 'dart:io'; + +import 'dart:convert'; +import 'fake_forecast.dart'; + +final forecastSchema = S.object( + properties: { + 'area_name': S.string(), + //"flags": S.object(), + 'current_condition': S.object( + properties: { + "temp_C": S.number(), + "temp_F": S.number(), + "humidity": S.number(), + "observation_time": S.string(), + }, + ), + 'weatherDesc': S.string(), + 'weatherIconUrl': S.string(), + }, +); + +final weatherCard = CatalogItem( + name: 'WeatherCard', + dataSchema: forecastSchema, + widgetBuilder: (itemContext) { + return WeatherCard(data: itemContext.data as Map); + }, +); + +class WeatherCard extends StatelessWidget { + final Map data; + const WeatherCard({super.key, required this.data}); + + @override + Widget build(BuildContext context) { + final currentCondition = data["current_condition"] as Map; + return Container( + width: 500, + padding: const EdgeInsets.all(20), + child: Card( + elevation: 4, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + child: Padding( + padding: const EdgeInsets.all(24.0), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: .end, + children: [ + Text( + "Observed at ${currentCondition["observation_time"]}", + style: TextStyle(fontSize: 12), + ), + ], + ), + const SizedBox(height: 8), + // Header: Icon and City Name + Row( + children: [ + Image.network( + data["weatherIconUrl"].toString(), + errorBuilder: (context, err, trace) { + return Icon(Icons.wb_sunny, size: 32,); + }, + ), + const SizedBox(width: 16), + Text( + data["area_name"].toString(), + style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold), + ), + ], + ), + const SizedBox(height: 24), + + // Temperature Display + Row( + children: [ + Text( + currentCondition["temp_C"]!.toString(), + style: TextStyle(fontSize: 56, fontWeight: FontWeight.bold), + ), + SizedBox(width: 8), + Text( + "°C", + style: TextStyle(fontSize: 32, fontWeight: FontWeight.w500), + ), + ], + ), + const SizedBox(height: 16), + + // Low Temp row + Row( + children: [ + const SizedBox(width: 16), + Text( + currentCondition['temp_F'].toString(), + style: TextStyle(fontSize: 28, fontWeight: FontWeight.w500), + ), + SizedBox(width: 8), + Text( + "°F", + style: TextStyle(fontSize: 20, color: Colors.grey), + ), + ], + ), + const SizedBox(height: 16), + + // Weather Condition + Text("${data['weatherDesc']}", style: TextStyle(fontSize: 22)), + ], + ), + ), + ), + ); + } +} +``` + +Add the `weatherCard` item to your catalog inside `lib/main.dart`: + +```dart +import 'package:genui_workshop/catalog/weather_card.dart'; +// ... + catalog = BasicCatalogItems.asCatalog().copyWith( + newItems: [weatherInput, weatherCard], + ); +``` + +Create a file called `lib/catalog/fake_forecast.dart` +```dart +var fakeForecast = { + 'area_name': 'Baltimore (Mount Clare)', + 'current_condition': { + 'temp_C': 22, + 'temp_F': 72, + 'humidity': 61, + 'observation_time': '02:25 PM', + }, + 'weatherDesc': ' Sunny', + 'weatherIconUrl': + 'https://cdn.worldweatheronline.com/images/wsymbols01_png_64/wsymbol_0001_sunny.png', +}; +``` + +Run the app. + +## Learn more + +For more information and detailed references on the technologies used in this workshop, check out the following resources: + +- **Flutter**: Discover guides, tutorials, and widget references at the [Official Flutter Documentation](https://docs.flutter.dev). +- **Firebase AI Logic**: Learn how to securely integrate generative AI models directly into your web and mobile apps on the [Firebase AI Logic Documentation](https://firebase.google.com/docs/ai-logic). +- **A2UI Protocol**: Understand the Agent-to-User Interface protocol specification and ecosystem at the [A2UI Official Site](https://a2ui.org). +- [**GenUI SDK for Flutter**](https://docs.flutter.dev/ai/genui) diff --git a/genui_workshop/analysis_options.yaml b/genui_workshop/analysis_options.yaml new file mode 100644 index 0000000..f9b3034 --- /dev/null +++ b/genui_workshop/analysis_options.yaml @@ -0,0 +1 @@ +include: package:flutter_lints/flutter.yaml diff --git a/genui_workshop/flutter_run_cloudshell.sh b/genui_workshop/flutter_run_cloudshell.sh new file mode 100755 index 0000000..255435f --- /dev/null +++ b/genui_workshop/flutter_run_cloudshell.sh @@ -0,0 +1,3 @@ +#!/bin/sh +flutter build web +python -m http.server 8080 --directory build/web diff --git a/genui_workshop/lib/catalog/fake_forecast.dart b/genui_workshop/lib/catalog/fake_forecast.dart new file mode 100644 index 0000000..e2c5b77 --- /dev/null +++ b/genui_workshop/lib/catalog/fake_forecast.dart @@ -0,0 +1,12 @@ +var fakeForecast = { + 'area_name': 'Baltimore (Mount Clare)', + 'current_condition': { + 'temp_C': 22, + 'temp_F': 72, + 'humidity': 61, + 'observation_time': '02:25 PM', + }, + 'weatherDesc': ' Sunny', + 'weatherIconUrl': + 'https://cdn.worldweatheronline.com/images/wsymbols01_png_64/wsymbol_0001_sunny.png', +}; \ No newline at end of file diff --git a/genui_workshop/lib/catalog/weather_card.dart b/genui_workshop/lib/catalog/weather_card.dart new file mode 100644 index 0000000..4f369ef --- /dev/null +++ b/genui_workshop/lib/catalog/weather_card.dart @@ -0,0 +1,123 @@ +import 'package:flutter/material.dart'; +import 'package:genui_workshop/genui_utils.dart'; +import 'package:genui/genui.dart'; +import 'package:json_schema_builder/json_schema_builder.dart'; +import 'dart:io'; + +import 'dart:convert'; +import 'fake_forecast.dart'; + +final forecastSchema = S.object( + properties: { + 'area_name': S.string(), + //"flags": S.object(), + 'current_condition': S.object( + properties: { + "temp_C": S.number(), + "temp_F": S.number(), + "humidity": S.number(), + "observation_time": S.string(), + }, + ), + 'weatherDesc': S.string(), + 'weatherIconUrl': S.string(), + }, +); + +final weatherCard = CatalogItem( + name: 'WeatherCard', + dataSchema: forecastSchema, + widgetBuilder: (itemContext) { + return WeatherCard(data: itemContext.data as Map); + }, +); + +class WeatherCard extends StatelessWidget { + final Map data; + const WeatherCard({super.key, required this.data}); + + @override + Widget build(BuildContext context) { + final currentCondition = data["current_condition"] as Map; + return Container( + width: 500, + padding: const EdgeInsets.all(20), + child: Card( + elevation: 4, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + child: Padding( + padding: const EdgeInsets.all(24.0), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: .end, + children: [ + Text( + "Observed at ${currentCondition["observation_time"]}", + style: TextStyle(fontSize: 12), + ), + ], + ), + const SizedBox(height: 8), + // Header: Icon and City Name + Row( + children: [ + Image.network( + data["weatherIconUrl"].toString(), + errorBuilder: (context, err, trace) { + return Icon(Icons.wb_sunny, size: 32,); + }, + ), + const SizedBox(width: 16), + Text( + data["area_name"].toString(), + style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold), + ), + ], + ), + const SizedBox(height: 24), + + // Temperature Display + Row( + children: [ + Text( + currentCondition["temp_C"]!.toString(), + style: TextStyle(fontSize: 56, fontWeight: FontWeight.bold), + ), + SizedBox(width: 8), + Text( + "°C", + style: TextStyle(fontSize: 32, fontWeight: FontWeight.w500), + ), + ], + ), + const SizedBox(height: 16), + + // Low Temp row + Row( + children: [ + const SizedBox(width: 16), + Text( + currentCondition['temp_F'].toString(), + style: TextStyle(fontSize: 28, fontWeight: FontWeight.w500), + ), + SizedBox(width: 8), + Text( + "°F", + style: TextStyle(fontSize: 20, color: Colors.grey), + ), + ], + ), + const SizedBox(height: 16), + + // Weather Condition + Text("${data['weatherDesc']}", style: TextStyle(fontSize: 22)), + ], + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/genui_workshop/lib/catalog/weather_input.dart b/genui_workshop/lib/catalog/weather_input.dart new file mode 100644 index 0000000..8df9eee --- /dev/null +++ b/genui_workshop/lib/catalog/weather_input.dart @@ -0,0 +1,147 @@ +import 'package:flutter/material.dart'; +import 'package:genui/genui.dart'; +import 'package:json_schema_builder/json_schema_builder.dart'; + +final simpleWeatherSchema = S.object( + properties: { + 'location': S.string(description: 'The location to check the weather.'), + 'date': S.string(description: 'The date to check the weather.'), + }, +); + +// Example raw action +//{version: v0.9, action: {name: submit_weather_request, sourceComponentId: submitButton, +//timestamp: 2026-05-26T22:10:19.087, context: {}, surfaceId: weather_input_surface}} +final weatherInput = CatalogItem( + name: 'WeatherInput', + dataSchema: simpleWeatherSchema, + widgetBuilder: (itemContext) { + final json = itemContext.data as Map; + final data = SimpleWeatherData.fromJson(json); + return WeatherInput( + data: data, + onFetchRequest: (loc, date) async { + final JsonMap resolvedContext = await resolveContext( + itemContext.dataContext, + {'location': loc, 'date': date.toString()}, + ); + itemContext.dispatchEvent( + UserActionEvent( + name: 'submit_weather_request', + sourceComponentId: 'submitButton', + timestamp: DateTime.now(), + context: resolvedContext + ), + ); + }, + ); + }, +); + +class SimpleWeatherData { + String location; + DateTime date; + + SimpleWeatherData({required this.location, required this.date}); + + factory SimpleWeatherData.defaultValues() { + return SimpleWeatherData(location: 'Baltimore', date: DateTime.now()); + } + + factory SimpleWeatherData.fromJson(Map json) { + if (json.isNotEmpty) { + return SimpleWeatherData( + location: json['location'] as String, + date: DateTime.parse(json['date'] as String), + ); + } else { + + return SimpleWeatherData(location: 'Baltimore', date: DateTime.now()); + } + } +} + +class WeatherInput extends StatefulWidget { + final SimpleWeatherData data; + final void Function(String, DateTime) onFetchRequest; + + const WeatherInput({ + super.key, + required this.data, + required this.onFetchRequest, + }); + + @override + State createState() => _WeatherInputState(); +} + +class _WeatherInputState extends State { + late TextEditingController _controller; + DateTime? selectedDate = DateTime.now(); + + Future _selectDate() async { + final DateTime? pickedDate = await showDatePicker( + context: context, + initialDate: DateTime.now(), + firstDate: DateTime.now().copyWith(month: 1, day: 1), + lastDate: DateTime.now().copyWith(month: 12, day: 31), + ); + + setState(() { + selectedDate = pickedDate; + }); + } + + @override + void initState() { + // TODO: implement initState + super.initState(); + _controller = TextEditingController(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + + @override + Widget build(BuildContext context) { + return Container( + width: 320, + padding: const EdgeInsets.all(16), + child: Card( + elevation: 4, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + child: Padding( + padding: const EdgeInsets.all(24.0), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text("Location", style: TextStyle(fontWeight: FontWeight.bold) ), + const SizedBox(height: 8), + TextField(decoration: InputDecoration(border: OutlineInputBorder()), controller: _controller,), + const SizedBox(height: 20), + SizedBox( + width: double.infinity, + height: 50, + child: OutlinedButton( + style: OutlinedButton.styleFrom( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(25)), + ), + onPressed: () { + + widget.onFetchRequest(_controller.text, selectedDate!); + }, + child: const Text("Get Forecast", style: TextStyle(color: Colors.black, fontSize: 16, fontWeight: FontWeight.bold)), + ), + ), + ], + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/genui_workshop/lib/genui_utils.dart b/genui_workshop/lib/genui_utils.dart new file mode 100644 index 0000000..5da06a3 --- /dev/null +++ b/genui_workshop/lib/genui_utils.dart @@ -0,0 +1,38 @@ +sealed class ConversationItem {} + +class TextItem extends ConversationItem { + final String text; + final bool isUser; + TextItem({required this.text, this.isUser = false}); +} + +class SurfaceItem extends ConversationItem { + final String surfaceId; + SurfaceItem({required this.surfaceId}); +} + +const systemInstruction = ''' + ## PERSONA + You are a meteorologist. + + ## GOAL + Work with me to produce of weather forecasts. + + ## RULES + + Do not offer opinions unless I ask for them. + + ## PROCESS + ### Planning + * Ask me for a location to check the weather. + * Follow up and ask for a date if not provided. + * Synthesize a list of weather forecasts from the provided information. + * Where available, you will use tool calls to retreive the info (not implemented yet) + * Advise if you are pulling the data from a real source or making it up. + * Ask clarifying questions if you need to. + * Respond to my suggestions for changes to date or location, if I have any. + + ## USER INTERFACE + * To request the location to retreive weather, create an instance of the WeatherInput + catalog item. +'''; \ No newline at end of file diff --git a/genui_workshop/lib/main.dart b/genui_workshop/lib/main.dart new file mode 100644 index 0000000..6e11973 --- /dev/null +++ b/genui_workshop/lib/main.dart @@ -0,0 +1,242 @@ +import 'package:flutter/material.dart'; +import 'package:firebase_core/firebase_core.dart'; +import 'package:firebase_ai/firebase_ai.dart'; +import 'package:genui_workshop/catalog/weather_card.dart'; +import 'package:genui_workshop/catalog/weather_input.dart'; +import 'package:genui_workshop/message_bubble.dart'; +import 'package:genui_workshop/genui_utils.dart'; +import 'firebase_options.dart'; + +import 'package:genui/genui.dart' hide TextPart; +import 'package:genui/genui.dart' as genui; +import 'package:genui_workshop/tool_calls.dart'; + +Future main() async { + WidgetsFlutterBinding.ensureInitialized(); + await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform); + runApp(const MyApp()); +} + +class MyApp extends StatelessWidget { + const MyApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'Weather Today', + theme: ThemeData( + colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue), + ), + home: const MyHomePage(), + ); + } +} + +class MyHomePage extends StatefulWidget { + const MyHomePage({super.key}); + + @override + State createState() => _MyHomePageState(); +} + +class _MyHomePageState extends State { + final List _items = []; + final _textController = TextEditingController(); + final _scrollController = ScrollController(); + late final ChatSession _chatSession; + + // Add GenUI controllers + late final SurfaceController _controller; + late final A2uiTransportAdapter _transport; + late final Conversation _conversation; + late final Catalog catalog; + + @override + void initState() { + super.initState(); + final model = FirebaseAI.googleAI().generativeModel( + model: 'gemini-3.1-flash-lite', + ); + + _chatSession = model.startChat(); + + // Initialize the GenUI Catalog. + // The genui package provides a default set of primitive widgets (like text + // and basic buttons) out of the box using this class. + catalog = BasicCatalogItems.asCatalog().copyWith( + newItems: [weatherInput,weatherCard ], + ); + + // Create a SurfaceController to manage the state of generated surfaces. + _controller = SurfaceController(catalogs: [catalog]); + + // Create a transport adapter that will process messages to and from the + // agent, looking for A2UI messages. + _transport = A2uiTransportAdapter(onSend: _sendAndReceive); + + // Link the transport and SurfaceController together in a Conversation, + // which provides your app a unified API for interacting with the agent. + _conversation = Conversation( + controller: _controller, + transport: _transport, + ); + + // Listen to GenUI stream events to update the UI + _conversation.events.listen((event) { + setState(() { + switch (event) { + case ConversationSurfaceAdded added: + _items.add(SurfaceItem(surfaceId: added.surfaceId)); + _scrollToBottom(); + case ConversationSurfaceRemoved removed: + _items.removeWhere( + (item) => + item is SurfaceItem && item.surfaceId == removed.surfaceId, + ); + case ConversationContentReceived content: + _items.add(TextItem(text: content.text, isUser: false)); + _scrollToBottom(); + case ConversationError error: + debugPrint('GenUI Error: ${error.error}'); + default: + } + }); + }); + + // Create the system prompt for the agent, which will include this app's + // system instruction as well as the schema for the catalog. + final promptBuilder = PromptBuilder.chat( + catalog: catalog, + systemPromptFragments: [systemInstruction], + ); + + // Send the prompt into the Conversation, which will subsequently route it + // to Firebase using the transport mechanism. + _conversation.sendRequest( + ChatMessage.system(promptBuilder.systemPromptJoined()), + ); + } + + void _scrollToBottom() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_scrollController.hasClients) { + _scrollController.animateTo( + _scrollController.position.maxScrollExtent, + duration: const Duration(milliseconds: 300), + curve: Curves.easeOut, + ); + } + }); + } + + @override + void dispose() { + _textController.dispose(); + _scrollController.dispose(); + super.dispose(); + } + +Future _addMessage() async { + final text = _textController.text; + + if (text.trim().isEmpty) { + return; + } + + _textController.clear(); + + setState(() { + _items.add(TextItem(text: text, isUser: true)); + }); + + _scrollToBottom(); + + // Send the user's input through GenUI instead of directly to Firebase. + await _conversation.sendRequest(ChatMessage.user(text)); + } + + Future _sendAndReceive(ChatMessage msg) async { + final buffer = StringBuffer(); + + // Reconstruct the message part fragments + for (final part in msg.parts) { + if (part.isUiInteractionPart) { + buffer.write(part.asUiInteractionPart!.interaction); + print(part.asUiInteractionPart!.interaction); + } else if (part is genui.TextPart) { + buffer.write(part.text); + } + } + + if (buffer.isEmpty) { + return; + } + + final text = buffer.toString(); + + // Send the string to Firebase AI Logic. + final response = await _chatSession.sendMessage(Content.text(text)); + + if (response.text?.isNotEmpty ?? false) { + // Feed the response back into GenUI's transportation layer + _transport.addChunk(response.text!); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + backgroundColor: Theme.of(context).colorScheme.inversePrimary, + title: const Text('Weather'), + ), + body: Column( + children: [ + Expanded( + child: ListView( + controller: _scrollController, + padding: const EdgeInsets.all(16), + children: [ + for (final item in _items) + switch (item) { + TextItem() => MessageBubble( + text: item.text, + isUser: item.isUser, + ), + SurfaceItem() => Surface( + surfaceContext: _controller.contextFor( + item.surfaceId, + ), + ), + }, + ], + ), + ), + SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Row( + children: [ + Expanded( + child: TextField( + controller: _textController, + onSubmitted: (_) => _addMessage(), + decoration: const InputDecoration( + hintText: 'Enter a message', + ), + ), + ), + const SizedBox(width: 8), + ElevatedButton( + onPressed: _addMessage, + child: const Text('Send'), + ), + ], + ), + ), + ), + ], + ), + ); + } +} diff --git a/genui_workshop/lib/message_bubble.dart b/genui_workshop/lib/message_bubble.dart new file mode 100644 index 0000000..05e4d25 --- /dev/null +++ b/genui_workshop/lib/message_bubble.dart @@ -0,0 +1,82 @@ +import 'package:flutter/material.dart'; + +class MessageBubble extends StatelessWidget { + final String text; + final bool isUser; + + const MessageBubble({super.key, required this.text, required this.isUser}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + final bubbleColor = isUser + ? colorScheme.primary + : colorScheme.surfaceContainerHighest; + + final textColor = isUser + ? colorScheme.onPrimary + : colorScheme.onSurfaceVariant; + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6.0, horizontal: 8.0), + child: Column( + crossAxisAlignment: isUser + ? CrossAxisAlignment.end + : CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: isUser + ? MainAxisAlignment.end + : MainAxisAlignment.start, + children: [ + Flexible( + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 16.0, + vertical: 12.0, + ), + decoration: BoxDecoration( + color: bubbleColor, + borderRadius: BorderRadius.only( + topLeft: const Radius.circular(20), + topRight: const Radius.circular(20), + bottomLeft: Radius.circular(isUser ? 20 : 0), + bottomRight: Radius.circular(isUser ? 0 : 20), + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withAlpha(20), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + gradient: isUser + ? LinearGradient( + colors: [ + colorScheme.primary, + colorScheme.primary.withAlpha(200), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ) + : null, + ), + child: Text( + text, + style: theme.textTheme.bodyLarge?.copyWith( + color: textColor, + height: 1.3, + ), + ), + ), + ), + ], + ), + const SizedBox(height: 2), + ], + ), + ); + } +} \ No newline at end of file diff --git a/genui_workshop/lib/tool_calls.dart b/genui_workshop/lib/tool_calls.dart new file mode 100644 index 0000000..501cb87 --- /dev/null +++ b/genui_workshop/lib/tool_calls.dart @@ -0,0 +1,15 @@ +import 'package:firebase_ai/firebase_ai.dart'; + +final fetchWeatherGeocodeTool = FunctionDeclaration( + 'fetchWeather', + 'Retrieves the weather for the current date and returns geocoded location data', + parameters: { + 'location': Schema.string( + description: 'The location for which to retrieve the weather', + ), + 'date': Schema.string( + description: + 'The date for which to retrieve the weather', + ), + }, +); \ No newline at end of file diff --git a/genui_workshop/pubspec.yaml b/genui_workshop/pubspec.yaml new file mode 100644 index 0000000..07bb949 --- /dev/null +++ b/genui_workshop/pubspec.yaml @@ -0,0 +1,23 @@ +name: genui_workshop +description: "A new Flutter project." +publish_to: 'none' +version: 0.1.0+1 + +environment: + sdk: ^3.12.2 + +dependencies: + firebase_ai: ^3.13.0 + firebase_core: ^4.11.0 + flutter: + sdk: flutter + genui: ^0.9.2 + json_schema_builder: ^0.1.5 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^6.0.0 + +flutter: + uses-material-design: true diff --git a/genui_workshop/steps.org b/genui_workshop/steps.org new file mode 100644 index 0000000..52327cd --- /dev/null +++ b/genui_workshop/steps.org @@ -0,0 +1,872 @@ +#+title: Steps + +## Step 1 + +Create a Firebase project at console.firebase.google.com. + +Enable Firebase AI logic in the project + +## Step 2: Activate Cloud Shell + +Cloud Shell space is persistent between projects so make sure you are creating things in proper project directories. + +## Step 3: Creating the project + +Cloud shell needs to link to the stored version of Flutter so we need the following command before running `flutter`. + +#+BEGIN_EXAMPLE shell +git config --global --add safe.directory /google/flutter +#+END_EXAMPLE + +We'll be starting with an empty project and adding code as we go. Warning that this might take a little while on Cloud Shell. + +#+BEGIN_EXAMPLE shell +flutter create --empty genui_workshop +#+END_EXAMPLE + +Next we can attach our local code to the previously created project with `flutterfire`. We only need the web target. + +#+BEGIN_EXAMPLE shell +dart pub global activate flutterfire_cli +export PATH="$PATH":"$HOME/.pub-cache/bin" + +cd +flutterfire configure +;; Select the project you just created +;; deselect all but web +#+END_EXAMPLE + +#+BEGIN_EXAMPLE shell +flutter pub add genui firebase_core firebase_ai json_schema_builder +#+END_EXAMPLE + +Let's run the app. + +Cloud shell doesn't allow `flutter run -d chrome` by default so we will test it by building the web target and starting a server. +#+BEGIN_EXAMPLE shell +flutter build web +cd build/web +python -m http.server 8080 + +#+END_EXAMPLE + +## Step 4: Setup GenUI scaffolding. + +Create =lib/genui_utils.dart= +#+BEGIN_EXAMPLE dart +sealed class ConversationItem {} + +class TextItem extends ConversationItem { + final String text; + final bool isUser; + TextItem({required this.text, this.isUser = false}); +} +#+END_EXAMPLE + +Create =lib/message_bubble.dart=. +#+BEGIN_EXAMPLE dart +import 'package:flutter/material.dart'; + +class MessageBubble extends StatelessWidget { + final String text; + final bool isUser; + + const MessageBubble({super.key, required this.text, required this.isUser}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + final bubbleColor = isUser + ? colorScheme.primary + : colorScheme.surfaceContainerHighest; + + final textColor = isUser + ? colorScheme.onPrimary + : colorScheme.onSurfaceVariant; + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6.0, horizontal: 8.0), + child: Column( + crossAxisAlignment: isUser + ? CrossAxisAlignment.end + : CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: isUser + ? MainAxisAlignment.end + : MainAxisAlignment.start, + children: [ + Flexible( + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 16.0, + vertical: 12.0, + ), + decoration: BoxDecoration( + color: bubbleColor, + borderRadius: BorderRadius.only( + topLeft: const Radius.circular(20), + topRight: const Radius.circular(20), + bottomLeft: Radius.circular(isUser ? 20 : 0), + bottomRight: Radius.circular(isUser ? 0 : 20), + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withAlpha(20), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + gradient: isUser + ? LinearGradient( + colors: [ + colorScheme.primary, + colorScheme.primary.withAlpha(200), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ) + : null, + ), + child: Text( + text, + style: theme.textTheme.bodyLarge?.copyWith( + color: textColor, + height: 1.3, + ), + ), + ), + ), + ], + ), + const SizedBox(height: 2), + ], + ), + ); + } +} + +#+END_EXAMPLE + +Paste the following to =main.dart= +#+BEGIN_EXAMPLE dart +import 'package:flutter/material.dart'; +import 'package:firebase_core/firebase_core.dart'; +import 'package:firebase_ai/firebase_ai.dart'; +import 'package:genui_workshop/message_bubble.dart'; +import 'package:genui_workshop/genui_utils.dart'; +import 'firebase_options.dart'; + +Future main() async { + WidgetsFlutterBinding.ensureInitialized(); + await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform); + runApp(const MyApp()); +} + +class MyApp extends StatelessWidget { + const MyApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'Weather Today', + theme: ThemeData( + colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue), + ), + home: const MyHomePage(), + ); + } +} + +class MyHomePage extends StatefulWidget { + const MyHomePage({super.key}); + + @override + State createState() => _MyHomePageState(); +} + +class _MyHomePageState extends State { + final List _items = []; + final _textController = TextEditingController(); + final _scrollController = ScrollController(); + late final ChatSession _chatSession; + + @override + void initState() { + super.initState(); + final model = FirebaseAI.googleAI().generativeModel( + model: 'gemini-3.5-flash', + ); + _chatSession = model.startChat(); + _chatSession.sendMessage(Content.text(systemInstruction)); + } + + void _scrollToBottom() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_scrollController.hasClients) { + _scrollController.animateTo( + _scrollController.position.maxScrollExtent, + duration: const Duration(milliseconds: 300), + curve: Curves.easeOut, + ); + } + }); + } + + @override + void dispose() { + _textController.dispose(); + _scrollController.dispose(); + super.dispose(); + } + + Future _addMessage() async { + final text = _textController.text; + + if (text.trim().isEmpty) { + return; + } + _textController.clear(); + + setState(() { + _items.add(TextItem(text: text, isUser: true)); + }); + + _scrollToBottom(); + + final response = await _chatSession.sendMessage(Content.text(text)); + + if (response.text?.isNotEmpty ?? false) { + setState(() { + _items.add(TextItem(text: response.text!, isUser: false)); + }); + _scrollToBottom(); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + backgroundColor: Theme.of(context).colorScheme.inversePrimary, + title: const Text('Weather'), + ), + body: Column( + children: [ + Expanded( + child: ListView( + controller: _scrollController, + padding: const EdgeInsets.all(16), + children: [ + for (final item in _items) + switch (item) { + TextItem() => MessageBubble( + text: item.text, + isUser: item.isUser, + ), + }, + ], + ), + ), + SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Row( + children: [ + Expanded( + child: TextField( + controller: _textController, + onSubmitted: (_) => _addMessage(), + decoration: const InputDecoration( + hintText: 'Enter a message', + ), + ), + ), + const SizedBox(width: 8), + ElevatedButton( + onPressed: _addMessage, + child: const Text('Send'), + ), + ], + ), + ), + ), + ], + ), + ); + } +} +#+END_EXAMPLE + + +Add the system instruction to =genui_utils.dart=. +#+BEGIN_EXAMPLE dart + const systemInstruction = ''' + ## PERSONA + You are a meteorologist. + + ## GOAL + Work with me to produce of weather forecasts. + + ## RULES + + Do not offer opinions unless I ask for them. + + ## PROCESS + ### Planning + * Ask me for a location to check the weather. + * Follow up and ask for a date if not provided. + * Synthesize a list of weather forecasts from the provided information. + * Where available, you will use tool calls to retreive the info (not implemented yet) + * Advise if you are pulling the data from a real source or making it up. + * Ask clarifying questions if you need to. + * Respond to my suggestions for changes to date or location, if I have any. +'''; +#+END_EXAMPLE + +Test app. It runs but doesn't really repsond. + +** Integrate GenUI package +Add the following to =genui_utils.dart= +#+BEGIN_EXAMPLE dart +class SurfaceItem extends ConversationItem { + final String surfaceId; + SurfaceItem({required this.surfaceId}); +} + +#+END_EXAMPLE + +In =main.dart=, add a case for the =SurfaceItem= type to the ListView =build= method: +#+BEGIN_EXAMPLE dart +Expanded( + child: ListView( + controller: _scrollController, + padding: const EdgeInsets.all(16), + children: [ + for (final item in _items) + switch (item) { + TextItem() => MessageBubble( + text: item.text, + isUser: item.isUser, + ), + // New! + SurfaceItem() => Surface( + surfaceContext: _controller.contextFor( + item.surfaceId, + ), + ), + }, + ], + ), +), +#+END_EXAMPLE + + +At the top of lib/main.dart, import the genui library: +#+begin_example dart +import 'package:genui/genui.dart' hide TextPart; +import 'package:genui/genui.dart' as genui; +#+end_example + + +Add the following lifecycle controllers and adapters to =main.dart= +#+begin_example dart +class _MyHomePageState extends State { + // ... existing members + late final ChatSession _chatSession; + + // Add GenUI controllers + late final SurfaceController _controller; + late final A2uiTransportAdapter _transport; + late final Conversation _conversation; + late final Catalog catalog; +#+end_example + +RUN. + +Update =_addMessage= and =sendAndReceive= like in code lab. + +#+begin_example dart + Future _addMessage() async { + final text = _textController.text; + + if (text.trim().isEmpty) { + return; + } + + _textController.clear(); + + setState(() { + _items.add(TextItem(text: text, isUser: true)); + }); + + _scrollToBottom(); + + // Send the user's input through GenUI instead of directly to Firebase. + await _conversation.sendRequest(ChatMessage.user(text)); + } +#+end_example + +#+begin_example dart + Future _sendAndReceive(ChatMessage msg) async { + final buffer = StringBuffer(); + + // Reconstruct the message part fragments + for (final part in msg.parts) { + if (part.isUiInteractionPart) { + buffer.write(part.asUiInteractionPart!.interaction); + } else if (part is genui.TextPart) { + buffer.write(part.text); + } + } + + if (buffer.isEmpty) { + return; + } + + final text = buffer.toString(); + + // Send the string to Firebase AI Logic. + final response = await _chatSession.sendMessage(Content.text(text)); + + if (response.text?.isNotEmpty ?? false) { + // Feed the response back into GenUI's transportation layer + _transport.addChunk(response.text!); + } + } +#+end_example + +TEST. + +### Actually wire up A2UI + +Add to the end of initState. +#+begin_example dart + // remove this line + // _chatSession.sendMessage(Content.text(systemInstruction)); + + // Initialize the GenUI Catalog. + // The genui package provides a default set of primitive widgets (like text + // and basic buttons) out of the box using this class. + catalog = BasicCatalogItems.asCatalog().copyWith( +// newItems: [weatherInput, weatherCard], + ); + + // Create a SurfaceController to manage the state of generated surfaces. + _controller = SurfaceController(catalogs: [catalog]); + + // Create a transport adapter that will process messages to and from the + // agent, looking for A2UI messages. + _transport = A2uiTransportAdapter(onSend: _sendAndReceive); + + // Link the transport and SurfaceController together in a Conversation, + // which provides your app a unified API for interacting with the agent. + _conversation = Conversation( + controller: _controller, + transport: _transport, + ); + + // Listen to GenUI stream events to update the UI + _conversation.events.listen((event) { + setState(() { + switch (event) { + case ConversationSurfaceAdded added: + _items.add(SurfaceItem(surfaceId: added.surfaceId)); + _scrollToBottom(); + case ConversationSurfaceRemoved removed: + _items.removeWhere( + (item) => + item is SurfaceItem && item.surfaceId == removed.surfaceId, + ); + case ConversationContentReceived content: + _items.add(TextItem(text: content.text, isUser: false)); + _scrollToBottom(); + case ConversationError error: + debugPrint('GenUI Error: ${error.error}'); + default: + } + }); + }); + + // Create the system prompt for the agent, which will include this app's + // system instruction as well as the schema for the catalog. + final promptBuilder = PromptBuilder.chat( + catalog: catalog, + systemPromptFragments: [systemInstruction], + ); + + // Send the prompt into the Conversation, which will subsequently route it + // to Firebase using the transport mechanism. + _conversation.sendRequest( + ChatMessage.system(promptBuilder.systemPromptJoined()), + ); +#+end_example + +We could explore doing some progress indicator UI here or not. + +** Make weather input widget +The components it picks is random and can't be submitted sometimes + +Create a =catalog= directory. One file per item with the schema included. + +Add the following to =catalog/weather_input.dart= +#+begin_example dart +import 'package:flutter/material.dart'; +import 'package:genui/genui.dart'; +import 'package:json_schema_builder/json_schema_builder.dart'; + +final simpleWeatherSchema = S.object( + properties: { + 'location': S.string(description: 'The location to check the weather.'), + 'date': S.string(description: 'The date to check the weather.'), + }, +); + +// Example raw action +//{version: v0.9, action: {name: submit_weather_request, sourceComponentId: submitButton, +//timestamp: 2026-05-26T22:10:19.087, context: {}, surfaceId: weather_input_surface}} +final weatherInput = CatalogItem( + name: 'WeatherInput', + dataSchema: simpleWeatherSchema, + widgetBuilder: (itemContext) { + final json = itemContext.data as Map; + final data = SimpleWeatherData.fromJson(json); + return WeatherInput( + data: data, + onFetchRequest: (loc, date) async { + final JsonMap resolvedContext = await resolveContext( + itemContext.dataContext, + {'location': loc, 'date': date.toString()}, + ); + itemContext.dispatchEvent( + UserActionEvent( + name: 'submit_weather_request', + sourceComponentId: 'submitButton', + timestamp: DateTime.now(), + context: resolvedContext + ), + ); + }, + ); + }, +); + +class SimpleWeatherData { + String location; + DateTime date; + + SimpleWeatherData({required this.location, required this.date}); + + factory SimpleWeatherData.defaultValues() { + return SimpleWeatherData(location: 'Baltimore', date: DateTime.now()); + } + + factory SimpleWeatherData.fromJson(Map json) { + if (json.isNotEmpty) { + return SimpleWeatherData( + location: json['location'] as String, + date: DateTime.parse(json['date'] as String), + ); + } else { + + return SimpleWeatherData(location: 'Baltimore', date: DateTime.now()); + } + } +} + +class WeatherInput extends StatefulWidget { + final SimpleWeatherData data; + final void Function(String, DateTime) onFetchRequest; + + const WeatherInput({ + super.key, + required this.data, + required this.onFetchRequest, + }); + + @override + State createState() => _WeatherInputState(); +} + +class _WeatherInputState extends State { + late TextEditingController _controller; + DateTime? selectedDate = DateTime.now(); + + Future _selectDate() async { + final DateTime? pickedDate = await showDatePicker( + context: context, + initialDate: DateTime.now(), + firstDate: DateTime.now().copyWith(month: 1, day: 1), + lastDate: DateTime.now().copyWith(month: 12, day: 31), + ); + + setState(() { + selectedDate = pickedDate; + }); + } + + @override + void initState() { + // TODO: implement initState + super.initState(); + _controller = TextEditingController(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + + @override + Widget build(BuildContext context) { + return Container( + width: 320, + padding: const EdgeInsets.all(16), + child: Card( + elevation: 4, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + child: Padding( + padding: const EdgeInsets.all(24.0), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text("Location", style: TextStyle(fontWeight: FontWeight.bold) ), + const SizedBox(height: 8), + TextField(decoration: InputDecoration(border: OutlineInputBorder()), controller: _controller,), + const SizedBox(height: 20), + /* const Text("Date", style: TextStyle( fontWeight: FontWeight.bold)), + const SizedBox(height: 8), +Row( + children: [ + Text(selectedDate != null ? '$selectedDate' : 'No date selected'), + const SizedBox(width: 16.0), + IconButton(onPressed: _selectDate, icon: Icon(Icons.calendar_today_outlined),) + + ], + ),*/ + // const SizedBox(height: 24), + SizedBox( + width: double.infinity, + height: 50, + child: OutlinedButton( + style: OutlinedButton.styleFrom( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(25)), + ), + onPressed: () { + + widget.onFetchRequest(_controller.text, selectedDate!); + }, + child: const Text("Get Forecast", style: TextStyle(color: Colors.black, fontSize: 16, fontWeight: FontWeight.bold)), + ), + ), + ], + ), + ), + ), + ); + } +} +#+end_example +- Add weatherInput as a new item in the basic catalog. +- Change the system prompt to point it to the catalog item + +Add this to end of the system instruction prompt. +#+begin_example dart + + ## USER INTERFACE + * To request the location to retreive weather, create an instance of the WeatherInput + catalog item. +#+end_example + +Test it. + +#+begin_example dart +import 'package:flutter/material.dart'; +import 'package:genui_workshop/genui_utils.dart'; +import 'package:genui/genui.dart'; +import 'package:json_schema_builder/json_schema_builder.dart'; +import 'dart:io'; + +import 'dart:convert'; +import 'fake_forecast.dart'; + +final forecastSchema = S.object( + properties: { + 'area_name': S.string(), + //"flags": S.object(), + 'current_condition': S.object( + properties: { + "temp_C": S.number(), + "temp_F": S.number(), + "humidity": S.number(), + "observation_time": S.string(), + }, + ), + 'weatherDesc': S.string(), + 'weatherIconUrl': S.string(), + }, +); + +final weatherCard = CatalogItem( + name: 'WeatherCard', + dataSchema: forecastSchema, + widgetBuilder: (itemContext) { + return WeatherCard(data: itemContext.data as Map); + }, +); + +class WeatherCard extends StatelessWidget { + final Map data; + const WeatherCard({super.key, required this.data}); + + @override + Widget build(BuildContext context) { + final currentCondition = data["current_condition"] as Map; + return Container( + width: 500, + padding: const EdgeInsets.all(20), + child: Card( + elevation: 4, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + child: Padding( + padding: const EdgeInsets.all(24.0), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: .end, + children: [ + Text( + "Observed at ${currentCondition["observation_time"]}", + style: TextStyle(fontSize: 12), + ), + ], + ), + const SizedBox(height: 8), + // Header: Icon and City Name + Row( + children: [ + Image.network( + data["weatherIconUrl"].toString(), + errorBuilder: (context, err, trace) { + return Icon(Icons.wb_sunny, size: 32,); + }, + ), + const SizedBox(width: 16), + Text( + data["area_name"].toString(), + style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold), + ), + ], + ), + const SizedBox(height: 24), + + // Temperature Display + Row( + children: [ + Text( + currentCondition["temp_C"]!.toString(), + style: TextStyle(fontSize: 56, fontWeight: FontWeight.bold), + ), + SizedBox(width: 8), + Text( + "°C", + style: TextStyle(fontSize: 32, fontWeight: FontWeight.w500), + ), + ], + ), + const SizedBox(height: 16), + + // Low Temp row + Row( + children: [ + const SizedBox(width: 16), + Text( + currentCondition['temp_F'].toString(), + style: TextStyle(fontSize: 28, fontWeight: FontWeight.w500), + ), + SizedBox(width: 8), + Text( + "°F", + style: TextStyle(fontSize: 20, color: Colors.grey), + ), + ], + ), + const SizedBox(height: 16), + + // Weather Condition + Text("${data['weatherDesc']}", style: TextStyle(fontSize: 22)), + ], + ), + ), + ), + ); + } +} +#+end_example + +Create a file called =catalog/fake_forecast.dart= +#+begin_example dart +var fakeForecast = { + 'area_name': 'Baltimore (Mount Clare)', + 'current_condition': { + 'temp_C': 22, + 'temp_F': 72, + 'humidity': 61, + 'observation_time': '02:25 PM', + }, + 'weatherDesc': ' Sunny', + 'weatherIconUrl': + 'https://cdn.worldweatheronline.com/images/wsymbols01_png_64/wsymbol_0001_sunny.png', +}; +#+end_example + +## Functions + +Good area to split to another tutorial. + +Create a file named =lib/tool_calls.dart=. + +#+begin_example dart +import 'package:firebase_ai/firebase_ai.dart'; + +final fetchWeatherGeocodeTool = FunctionDeclaration( + 'fetchWeather', + 'Retrieves the weather for the current date and returns geocoded location data', + parameters: { + 'location': Schema.string( + description: 'The location for which to retrieve the weather', + ), + 'date': Schema.string( + description: + 'The date for which to retrieve the weather', + ), + }, +); +#+end_example + +Add the import to the top of =main.dart=. +#+begin_example dart +import 'package:genui_workshop/tool_calls.dart'; +#+end_example + +Update the model in =initState= from main.dart to the following: +#+begin_example dart +final model = FirebaseAI.googleAI().generativeModel( + //model: 'gemini-3.5-flash', + model: 'gemini-3.1-flash-lite', + // tools that will call cloud functions + tools: [ + Tool.functionDeclarations([fetchWeatherGeocodeTool]), + ], + ); +#+end_example + +.... +TBD diff --git a/genui_workshop/web/favicon.png b/genui_workshop/web/favicon.png new file mode 100644 index 0000000..8aaa46a Binary files /dev/null and b/genui_workshop/web/favicon.png differ diff --git a/genui_workshop/web/icons/Icon-192.png b/genui_workshop/web/icons/Icon-192.png new file mode 100644 index 0000000..b749bfe Binary files /dev/null and b/genui_workshop/web/icons/Icon-192.png differ diff --git a/genui_workshop/web/icons/Icon-512.png b/genui_workshop/web/icons/Icon-512.png new file mode 100644 index 0000000..88cfd48 Binary files /dev/null and b/genui_workshop/web/icons/Icon-512.png differ diff --git a/genui_workshop/web/icons/Icon-maskable-192.png b/genui_workshop/web/icons/Icon-maskable-192.png new file mode 100644 index 0000000..eb9b4d7 Binary files /dev/null and b/genui_workshop/web/icons/Icon-maskable-192.png differ diff --git a/genui_workshop/web/icons/Icon-maskable-512.png b/genui_workshop/web/icons/Icon-maskable-512.png new file mode 100644 index 0000000..d69c566 Binary files /dev/null and b/genui_workshop/web/icons/Icon-maskable-512.png differ diff --git a/genui_workshop/web/index.html b/genui_workshop/web/index.html new file mode 100644 index 0000000..db370f9 --- /dev/null +++ b/genui_workshop/web/index.html @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + genui_workshop + + + + + + + diff --git a/genui_workshop/web/manifest.json b/genui_workshop/web/manifest.json new file mode 100644 index 0000000..89706d1 --- /dev/null +++ b/genui_workshop/web/manifest.json @@ -0,0 +1,35 @@ +{ + "name": "genui_workshop", + "short_name": "genui_workshop", + "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/native_interop_demos/ios_calendar_demo/.gitignore b/native_interop_demos/ios_calendar_demo/.gitignore new file mode 100644 index 0000000..79f7eca --- /dev/null +++ b/native_interop_demos/ios_calendar_demo/.gitignore @@ -0,0 +1,48 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ +/coverage/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release + +# Widget Preview related +.widget_preview/ diff --git a/native_interop_demos/ios_calendar_demo/.metadata b/native_interop_demos/ios_calendar_demo/.metadata new file mode 100644 index 0000000..d299546 --- /dev/null +++ b/native_interop_demos/ios_calendar_demo/.metadata @@ -0,0 +1,33 @@ +# 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: "e1e42652202b428d9e9eaa91fe00d5952587e7ca" + channel: "master" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: e1e42652202b428d9e9eaa91fe00d5952587e7ca + base_revision: e1e42652202b428d9e9eaa91fe00d5952587e7ca + - platform: ios + create_revision: e1e42652202b428d9e9eaa91fe00d5952587e7ca + base_revision: e1e42652202b428d9e9eaa91fe00d5952587e7ca + - platform: macos + create_revision: e1e42652202b428d9e9eaa91fe00d5952587e7ca + base_revision: e1e42652202b428d9e9eaa91fe00d5952587e7ca + + # 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/native_interop_demos/ios_calendar_demo/README.md b/native_interop_demos/ios_calendar_demo/README.md new file mode 100644 index 0000000..5e65e60 --- /dev/null +++ b/native_interop_demos/ios_calendar_demo/README.md @@ -0,0 +1,11 @@ +# ios_calendar_demo + +A demonstration of using Dart native interop to communicate with the iOS Calendar. + +## To build + +```sh +dart run tool/generate_code.dart # To build Dart bindings +..... # Start a simulator +flutter run +``` diff --git a/native_interop_demos/ios_calendar_demo/analysis_options.yaml b/native_interop_demos/ios_calendar_demo/analysis_options.yaml new file mode 100644 index 0000000..f9b3034 --- /dev/null +++ b/native_interop_demos/ios_calendar_demo/analysis_options.yaml @@ -0,0 +1 @@ +include: package:flutter_lints/flutter.yaml diff --git a/native_interop_demos/ios_calendar_demo/ios/.gitignore b/native_interop_demos/ios_calendar_demo/ios/.gitignore new file mode 100644 index 0000000..7a7f987 --- /dev/null +++ b/native_interop_demos/ios_calendar_demo/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/native_interop_demos/ios_calendar_demo/ios/Flutter/AppFrameworkInfo.plist b/native_interop_demos/ios_calendar_demo/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 0000000..391a902 --- /dev/null +++ b/native_interop_demos/ios_calendar_demo/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,24 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + + diff --git a/native_interop_demos/ios_calendar_demo/ios/Flutter/Debug.xcconfig b/native_interop_demos/ios_calendar_demo/ios/Flutter/Debug.xcconfig new file mode 100644 index 0000000..592ceee --- /dev/null +++ b/native_interop_demos/ios_calendar_demo/ios/Flutter/Debug.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/native_interop_demos/ios_calendar_demo/ios/Flutter/Release.xcconfig b/native_interop_demos/ios_calendar_demo/ios/Flutter/Release.xcconfig new file mode 100644 index 0000000..592ceee --- /dev/null +++ b/native_interop_demos/ios_calendar_demo/ios/Flutter/Release.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/native_interop_demos/ios_calendar_demo/ios/Runner.xcodeproj/project.pbxproj b/native_interop_demos/ios_calendar_demo/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..a962af5 --- /dev/null +++ b/native_interop_demos/ios_calendar_demo/ios/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,644 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXBuildFile section */ + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; }; + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 97C146ED1CF9000F007C117D; + remoteInfo = Runner; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 9705A1C41CF9048500538489 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; + 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; + 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 97C146EB1CF9000F007C117D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage 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 = ( + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 9740EEB31CF90195004384FC /* Generated.xcconfig */, + ); + name = Flutter; + sourceTree = ""; + }; + 97C146E51CF9000F007C117D = { + isa = PBXGroup; + children = ( + 9740EEB11CF90186004384FC /* Flutter */, + 97C146F01CF9000F007C117D /* Runner */, + 97C146EF1CF9000F007C117D /* Products */, + 331C8082294A63A400263BE5 /* RunnerTests */, + ); + sourceTree = ""; + }; + 97C146EF1CF9000F007C117D /* Products */ = { + isa = PBXGroup; + children = ( + 97C146EE1CF9000F007C117D /* Runner.app */, + 331C8081294A63A400263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 97C146F01CF9000F007C117D /* Runner */ = { + isa = PBXGroup; + children = ( + 97C146FA1CF9000F007C117D /* Main.storyboard */, + 97C146FD1CF9000F007C117D /* Assets.xcassets */, + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, + 97C147021CF9000F007C117D /* Info.plist */, + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */, + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + ); + path = Runner; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C8080294A63A400263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 331C807D294A63A400263BE5 /* Sources */, + 331C807F294A63A400263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C8086294A63A400263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 97C146ED1CF9000F007C117D /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 9740EEB61CF901F6004384FC /* Run Script */, + 97C146EA1CF9000F007C117D /* Sources */, + 97C146EB1CF9000F007C117D /* Frameworks */, + 97C146EC1CF9000F007C117D /* Resources */, + 9705A1C41CF9048500538489 /* Embed Frameworks */, + 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Runner; + packageProductDependencies = ( + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, + ); + 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; + packageReferences = ( + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */, + ); + productRefGroup = 97C146EF1CF9000F007C117D /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 97C146ED1CF9000F007C117D /* Runner */, + 331C8080294A63A400263BE5 /* RunnerTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C807F294A63A400263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EC1CF9000F007C117D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", + ); + name = "Thin Binary"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; + }; + 9740EEB61CF901F6004384FC /* Run Script */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Run Script"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C807D294A63A400263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EA1CF9000F007C117D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, + 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C8086294A63A400263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 97C146ED1CF9000F007C117D /* Runner */; + targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 97C146FA1CF9000F007C117D /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C146FB1CF9000F007C117D /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C147001CF9000F007C117D /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 249021D3217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Profile; + }; + 249021D4217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.iosCalendarDemo; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Profile; + }; + 331C8088294A63A400263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.iosCalendarDemo.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Debug; + }; + 331C8089294A63A400263BE5 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.iosCalendarDemo.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Release; + }; + 331C808A294A63A400263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.iosCalendarDemo.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 = 13.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 97C147041CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 97C147061CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.iosCalendarDemo; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + 97C147071CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.iosCalendarDemo; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C8088294A63A400263BE5 /* Debug */, + 331C8089294A63A400263BE5 /* Release */, + 331C808A294A63A400263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147031CF9000F007C117D /* Debug */, + 97C147041CF9000F007C117D /* Release */, + 249021D3217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147061CF9000F007C117D /* Debug */, + 97C147071CF9000F007C117D /* Release */, + 249021D4217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = { + isa = XCSwiftPackageProductDependency; + productName = FlutterGeneratedPluginSwiftPackage; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 97C146E61CF9000F007C117D /* Project object */; +} diff --git a/native_interop_demos/ios_calendar_demo/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/native_interop_demos/ios_calendar_demo/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/native_interop_demos/ios_calendar_demo/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/native_interop_demos/ios_calendar_demo/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/native_interop_demos/ios_calendar_demo/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/native_interop_demos/ios_calendar_demo/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/native_interop_demos/ios_calendar_demo/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/native_interop_demos/ios_calendar_demo/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/native_interop_demos/ios_calendar_demo/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/native_interop_demos/ios_calendar_demo/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/native_interop_demos/ios_calendar_demo/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..c3fedb2 --- /dev/null +++ b/native_interop_demos/ios_calendar_demo/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,119 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/native_interop_demos/ios_calendar_demo/ios/Runner.xcworkspace/contents.xcworkspacedata b/native_interop_demos/ios_calendar_demo/ios/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..1d526a1 --- /dev/null +++ b/native_interop_demos/ios_calendar_demo/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/native_interop_demos/ios_calendar_demo/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/native_interop_demos/ios_calendar_demo/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/native_interop_demos/ios_calendar_demo/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/native_interop_demos/ios_calendar_demo/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/native_interop_demos/ios_calendar_demo/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/native_interop_demos/ios_calendar_demo/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/native_interop_demos/ios_calendar_demo/ios/Runner/AppDelegate.swift b/native_interop_demos/ios_calendar_demo/ios/Runner/AppDelegate.swift new file mode 100644 index 0000000..c30b367 --- /dev/null +++ b/native_interop_demos/ios_calendar_demo/ios/Runner/AppDelegate.swift @@ -0,0 +1,16 @@ +import Flutter +import UIKit + +@main +@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } + + func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { + GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) + } +} diff --git a/native_interop_demos/ios_calendar_demo/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/native_interop_demos/ios_calendar_demo/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..d36b1fa --- /dev/null +++ b/native_interop_demos/ios_calendar_demo/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/native_interop_demos/ios_calendar_demo/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/native_interop_demos/ios_calendar_demo/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 0000000..dc9ada4 Binary files /dev/null and b/native_interop_demos/ios_calendar_demo/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/native_interop_demos/ios_calendar_demo/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/native_interop_demos/ios_calendar_demo/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png new file mode 100644 index 0000000..7353c41 Binary files /dev/null and b/native_interop_demos/ios_calendar_demo/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/native_interop_demos/ios_calendar_demo/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/native_interop_demos/ios_calendar_demo/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 0000000..797d452 Binary files /dev/null and b/native_interop_demos/ios_calendar_demo/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/native_interop_demos/ios_calendar_demo/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/native_interop_demos/ios_calendar_demo/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 0000000..6ed2d93 Binary files /dev/null and b/native_interop_demos/ios_calendar_demo/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/native_interop_demos/ios_calendar_demo/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/native_interop_demos/ios_calendar_demo/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png new file mode 100644 index 0000000..4cd7b00 Binary files /dev/null and b/native_interop_demos/ios_calendar_demo/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/native_interop_demos/ios_calendar_demo/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/native_interop_demos/ios_calendar_demo/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 0000000..fe73094 Binary files /dev/null and b/native_interop_demos/ios_calendar_demo/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/native_interop_demos/ios_calendar_demo/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/native_interop_demos/ios_calendar_demo/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png new file mode 100644 index 0000000..321773c Binary files /dev/null and b/native_interop_demos/ios_calendar_demo/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/native_interop_demos/ios_calendar_demo/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/native_interop_demos/ios_calendar_demo/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png new file mode 100644 index 0000000..797d452 Binary files /dev/null and b/native_interop_demos/ios_calendar_demo/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/native_interop_demos/ios_calendar_demo/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/native_interop_demos/ios_calendar_demo/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 0000000..502f463 Binary files /dev/null and b/native_interop_demos/ios_calendar_demo/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/native_interop_demos/ios_calendar_demo/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/native_interop_demos/ios_calendar_demo/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png new file mode 100644 index 0000000..0ec3034 Binary files /dev/null and b/native_interop_demos/ios_calendar_demo/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/native_interop_demos/ios_calendar_demo/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/native_interop_demos/ios_calendar_demo/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 0000000..0ec3034 Binary files /dev/null and b/native_interop_demos/ios_calendar_demo/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/native_interop_demos/ios_calendar_demo/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/native_interop_demos/ios_calendar_demo/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 0000000..e9f5fea Binary files /dev/null and b/native_interop_demos/ios_calendar_demo/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/native_interop_demos/ios_calendar_demo/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/native_interop_demos/ios_calendar_demo/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png new file mode 100644 index 0000000..84ac32a Binary files /dev/null and b/native_interop_demos/ios_calendar_demo/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/native_interop_demos/ios_calendar_demo/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/native_interop_demos/ios_calendar_demo/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png new file mode 100644 index 0000000..8953cba Binary files /dev/null and b/native_interop_demos/ios_calendar_demo/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/native_interop_demos/ios_calendar_demo/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/native_interop_demos/ios_calendar_demo/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/native_interop_demos/ios_calendar_demo/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/native_interop_demos/ios_calendar_demo/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/native_interop_demos/ios_calendar_demo/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json new file mode 100644 index 0000000..0bedcf2 --- /dev/null +++ b/native_interop_demos/ios_calendar_demo/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/native_interop_demos/ios_calendar_demo/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/native_interop_demos/ios_calendar_demo/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/native_interop_demos/ios_calendar_demo/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ diff --git a/native_interop_demos/ios_calendar_demo/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/native_interop_demos/ios_calendar_demo/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/native_interop_demos/ios_calendar_demo/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ diff --git a/native_interop_demos/ios_calendar_demo/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/native_interop_demos/ios_calendar_demo/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/native_interop_demos/ios_calendar_demo/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ diff --git a/native_interop_demos/ios_calendar_demo/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/native_interop_demos/ios_calendar_demo/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md new file mode 100644 index 0000000..89c2725 --- /dev/null +++ b/native_interop_demos/ios_calendar_demo/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/native_interop_demos/ios_calendar_demo/ios/Runner/Base.lproj/LaunchScreen.storyboard b/native_interop_demos/ios_calendar_demo/ios/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..f2e259c --- /dev/null +++ b/native_interop_demos/ios_calendar_demo/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/native_interop_demos/ios_calendar_demo/ios/Runner/Base.lproj/Main.storyboard b/native_interop_demos/ios_calendar_demo/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 0000000..f3c2851 --- /dev/null +++ b/native_interop_demos/ios_calendar_demo/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/native_interop_demos/ios_calendar_demo/ios/Runner/Info.plist b/native_interop_demos/ios_calendar_demo/ios/Runner/Info.plist new file mode 100644 index 0000000..e054c73 --- /dev/null +++ b/native_interop_demos/ios_calendar_demo/ios/Runner/Info.plist @@ -0,0 +1,72 @@ + + + + + NSCalendarsFullAccessUsageDescription + This app needs calendar access to create and manage your events. + CADisableMinimumFrameDurationOnPhone + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Ios Calendar Demo + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + ios_calendar_demo + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneClassName + UIWindowScene + UISceneConfigurationName + flutter + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + UISceneStoryboardFile + Main + + + + + UIApplicationSupportsIndirectInputEvents + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/native_interop_demos/ios_calendar_demo/ios/Runner/Runner-Bridging-Header.h b/native_interop_demos/ios_calendar_demo/ios/Runner/Runner-Bridging-Header.h new file mode 100644 index 0000000..308a2a5 --- /dev/null +++ b/native_interop_demos/ios_calendar_demo/ios/Runner/Runner-Bridging-Header.h @@ -0,0 +1 @@ +#import "GeneratedPluginRegistrant.h" diff --git a/native_interop_demos/ios_calendar_demo/ios/Runner/SceneDelegate.swift b/native_interop_demos/ios_calendar_demo/ios/Runner/SceneDelegate.swift new file mode 100644 index 0000000..b9ce8ea --- /dev/null +++ b/native_interop_demos/ios_calendar_demo/ios/Runner/SceneDelegate.swift @@ -0,0 +1,6 @@ +import Flutter +import UIKit + +class SceneDelegate: FlutterSceneDelegate { + +} diff --git a/native_interop_demos/ios_calendar_demo/ios/RunnerTests/RunnerTests.swift b/native_interop_demos/ios_calendar_demo/ios/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..86a7c3b --- /dev/null +++ b/native_interop_demos/ios_calendar_demo/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/native_interop_demos/ios_calendar_demo/lib/create_event_dialog.dart b/native_interop_demos/ios_calendar_demo/lib/create_event_dialog.dart new file mode 100644 index 0000000..5294d8d --- /dev/null +++ b/native_interop_demos/ios_calendar_demo/lib/create_event_dialog.dart @@ -0,0 +1,144 @@ +import 'package:flutter/material.dart'; +import 'package:objective_c/objective_c.dart'; + +import 'eventkit_bindings.dart'; + +class CreateEventDialog extends StatefulWidget { + final EKEventStore eventStore; + final VoidCallback onEventCreated; + + const CreateEventDialog({ + super.key, + required this.eventStore, + required this.onEventCreated, + }); + + @override + State createState() => _CreateEventDialogState(); +} + +class _CreateEventDialogState extends State { + final _titleController = TextEditingController(); + final _notesController = TextEditingController(); + DateTime _startDate = DateTime.now(); + DateTime _endDate = DateTime.now().add(const Duration(hours: 1)); + + Future _selectDateTime(BuildContext context, bool isStart) async { + final initialDate = isStart ? _startDate : _endDate; + final pickedDate = await showDatePicker( + context: context, + initialDate: initialDate, + firstDate: DateTime(2000), + lastDate: DateTime(2100), + ); + if (pickedDate == null) return; + + if (!context.mounted) return; + + final pickedTime = await showTimePicker( + context: context, + initialTime: TimeOfDay.fromDateTime(initialDate), + ); + if (pickedTime == null) return; + + final finalDateTime = DateTime( + pickedDate.year, + pickedDate.month, + pickedDate.day, + pickedTime.hour, + pickedTime.minute, + ); + + setState(() { + if (isStart) { + _startDate = finalDateTime; + if (_endDate.isBefore(_startDate)) { + _endDate = _startDate.add(const Duration(hours: 1)); + } + } else { + _endDate = finalDateTime; + } + }); + } + + void _saveEvent() { + final event = EKEvent.eventWithEventStore(widget.eventStore); + event.title = _titleController.text.toNSString(); + event.notes = _notesController.text.toNSString(); + event.startDate = _startDate.toNSDate(); + event.endDate = _endDate.toNSDate(); + + final defaultCalendar = widget.eventStore.defaultCalendarForNewEvents; + if (defaultCalendar != null) { + event.calendar = defaultCalendar; + final success = widget.eventStore.saveEvent( + event, + span: EKSpan.EKSpanThisEvent, + commit: true, + ); + if (success) { + widget.onEventCreated(); + } else { + debugPrint('Failed to save event'); + } + } else { + debugPrint('No default calendar found'); + } + + Navigator.of(context).pop(); + } + + @override + void dispose() { + _titleController.dispose(); + _notesController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text('Create Event'), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: _titleController, + decoration: const InputDecoration(labelText: 'Title'), + ), + TextField( + controller: _notesController, + decoration: const InputDecoration(labelText: 'Notes'), + maxLines: 3, + ), + ListTile( + contentPadding: EdgeInsets.zero, + title: const Text('Start Date'), + subtitle: Text('${_startDate.year}-${_startDate.month.toString().padLeft(2, '0')}-${_startDate.day.toString().padLeft(2, '0')} ${_startDate.hour.toString().padLeft(2, '0')}:${_startDate.minute.toString().padLeft(2, '0')}'), + onTap: () => _selectDateTime(context, true), + trailing: const Icon(Icons.calendar_today), + ), + ListTile( + contentPadding: EdgeInsets.zero, + title: const Text('End Date'), + subtitle: Text('${_endDate.year}-${_endDate.month.toString().padLeft(2, '0')}-${_endDate.day.toString().padLeft(2, '0')} ${_endDate.hour.toString().padLeft(2, '0')}:${_endDate.minute.toString().padLeft(2, '0')}'), + onTap: () => _selectDateTime(context, false), + trailing: const Icon(Icons.calendar_today), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + ElevatedButton( + onPressed: _saveEvent, + child: const Text('Save'), + ), + ], + ); + } +} diff --git a/native_interop_demos/ios_calendar_demo/lib/eventkit_bindings.dart.m b/native_interop_demos/ios_calendar_demo/lib/eventkit_bindings.dart.m new file mode 100644 index 0000000..2ef47c3 --- /dev/null +++ b/native_interop_demos/ios_calendar_demo/lib/eventkit_bindings.dart.m @@ -0,0 +1,122 @@ +#include +#import +#import +#import + +#if !__has_feature(objc_arc) +#error "This file must be compiled with ARC enabled" +#endif + +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wundeclared-selector" + +typedef struct { + int64_t version; + void* (*newWaiter)(void); + void (*awaitWaiter)(void*); + void* (*currentIsolate)(void); + void (*enterIsolate)(void*); + void (*exitIsolate)(void); + int64_t (*getMainPortId)(void); + bool (*getCurrentThreadOwnsIsolate)(int64_t); +} DOBJC_Context; + +id objc_retainBlock(id); + +#define BLOCKING_BLOCK_IMPL(ctx, BLOCK_SIG, INVOKE_DIRECT, INVOKE_LISTENER) \ + assert(ctx->version >= 1); \ + void* targetIsolate = ctx->currentIsolate(); \ + int64_t targetPort = ctx->getMainPortId == NULL ? 0 : ctx->getMainPortId(); \ + return BLOCK_SIG { \ + void* currentIsolate = ctx->currentIsolate(); \ + bool mayEnterIsolate = \ + currentIsolate == NULL && \ + ctx->getCurrentThreadOwnsIsolate != NULL && \ + ctx->getCurrentThreadOwnsIsolate(targetPort); \ + if (currentIsolate == targetIsolate || mayEnterIsolate) { \ + if (mayEnterIsolate) { \ + ctx->enterIsolate(targetIsolate); \ + } \ + INVOKE_DIRECT; \ + if (mayEnterIsolate) { \ + ctx->exitIsolate(); \ + } \ + } else { \ + void* waiter = ctx->newWaiter(); \ + INVOKE_LISTENER; \ + ctx->awaitWaiter(waiter); \ + } \ + }; + + +typedef void (^_ListenerTrampoline)(BOOL arg0, id arg1); +__attribute__((visibility("default"))) __attribute__((used)) +_ListenerTrampoline _NativeLibrary_wrapListenerBlock_hk7n97(_ListenerTrampoline block) NS_RETURNS_RETAINED { + return ^void(BOOL arg0, id arg1) { + objc_retainBlock(block); + block(arg0, (__bridge id)(__bridge_retained void*)(arg1)); + }; +} + +typedef void (^_BlockingTrampoline)(void * waiter, BOOL arg0, id arg1); +__attribute__((visibility("default"))) __attribute__((used)) +_ListenerTrampoline _NativeLibrary_wrapBlockingBlock_hk7n97( + _BlockingTrampoline block, _BlockingTrampoline listenerBlock, + DOBJC_Context* ctx) NS_RETURNS_RETAINED { + BLOCKING_BLOCK_IMPL(ctx, ^void(BOOL arg0, id arg1), { + objc_retainBlock(block); + block(nil, arg0, (__bridge id)(__bridge_retained void*)(arg1)); + }, { + objc_retainBlock(listenerBlock); + listenerBlock(waiter, arg0, (__bridge id)(__bridge_retained void*)(arg1)); + }); +} + +typedef void (^_ListenerTrampoline_1)(id arg0, BOOL * arg1); +__attribute__((visibility("default"))) __attribute__((used)) +_ListenerTrampoline_1 _NativeLibrary_wrapListenerBlock_t8l8el(_ListenerTrampoline_1 block) NS_RETURNS_RETAINED { + return ^void(id arg0, BOOL * arg1) { + objc_retainBlock(block); + block((__bridge id)(__bridge_retained void*)(arg0), arg1); + }; +} + +typedef void (^_BlockingTrampoline_1)(void * waiter, id arg0, BOOL * arg1); +__attribute__((visibility("default"))) __attribute__((used)) +_ListenerTrampoline_1 _NativeLibrary_wrapBlockingBlock_t8l8el( + _BlockingTrampoline_1 block, _BlockingTrampoline_1 listenerBlock, + DOBJC_Context* ctx) NS_RETURNS_RETAINED { + BLOCKING_BLOCK_IMPL(ctx, ^void(id arg0, BOOL * arg1), { + objc_retainBlock(block); + block(nil, (__bridge id)(__bridge_retained void*)(arg0), arg1); + }, { + objc_retainBlock(listenerBlock); + listenerBlock(waiter, (__bridge id)(__bridge_retained void*)(arg0), arg1); + }); +} + +typedef void (^_ListenerTrampoline_2)(id arg0); +__attribute__((visibility("default"))) __attribute__((used)) +_ListenerTrampoline_2 _NativeLibrary_wrapListenerBlock_xtuoz7(_ListenerTrampoline_2 block) NS_RETURNS_RETAINED { + return ^void(id arg0) { + objc_retainBlock(block); + block((__bridge id)(__bridge_retained void*)(arg0)); + }; +} + +typedef void (^_BlockingTrampoline_2)(void * waiter, id arg0); +__attribute__((visibility("default"))) __attribute__((used)) +_ListenerTrampoline_2 _NativeLibrary_wrapBlockingBlock_xtuoz7( + _BlockingTrampoline_2 block, _BlockingTrampoline_2 listenerBlock, + DOBJC_Context* ctx) NS_RETURNS_RETAINED { + BLOCKING_BLOCK_IMPL(ctx, ^void(id arg0), { + objc_retainBlock(block); + block(nil, (__bridge id)(__bridge_retained void*)(arg0)); + }, { + objc_retainBlock(listenerBlock); + listenerBlock(waiter, (__bridge id)(__bridge_retained void*)(arg0)); + }); +} +#undef BLOCKING_BLOCK_IMPL + +#pragma clang diagnostic pop diff --git a/native_interop_demos/ios_calendar_demo/lib/main.dart b/native_interop_demos/ios_calendar_demo/lib/main.dart new file mode 100644 index 0000000..6dd198c --- /dev/null +++ b/native_interop_demos/ios_calendar_demo/lib/main.dart @@ -0,0 +1,171 @@ +import 'package:flutter/material.dart'; +import 'package:objective_c/objective_c.dart'; +import 'package:permission_handler/permission_handler.dart'; + +import 'create_event_dialog.dart'; +import 'eventkit_bindings.dart'; + +void main() { + runApp(const MainApp()); +} + +class MainApp extends StatelessWidget { + const MainApp({super.key}); + + @override + Widget build(BuildContext context) { + return const MaterialApp(home: CalendarHomePage()); + } +} + +class CalendarHomePage extends StatefulWidget { + const CalendarHomePage({super.key}); + + @override + State createState() => _CalendarHomePageState(); +} + +class _CalendarHomePageState extends State { + bool _hasCalendarPermission = false; + List> _events = []; + late final EKEventStore _eventStore; + + @override + void initState() { + super.initState(); + _eventStore = EKEventStore(); + _checkPermission(); + } + + Future _checkPermission() async { + final status = await Permission.calendarFullAccess.status; + setState(() { + _hasCalendarPermission = status.isGranted; + }); + } + + Future _requestPermission() async { + await Permission.calendarFullAccess + .onGrantedCallback(() { + debugPrint('Granted'); + setState(() { + _hasCalendarPermission = true; + }); + }) + .onDeniedCallback(() { + debugPrint('denied'); + }) + .request(); + } + + void _retrieveEvents() { + final startDate = NSDate.date(); + final endDate = NSDate.dateWithTimeIntervalSinceNow(30.0 * 24 * 60 * 60); + + final calendars = _eventStore.calendars; + final predicate = _eventStore.predicateForEventsWithStartDate( + startDate, + endDate: endDate, + calendars: calendars, + ); + + final NSArray eventsArray = _eventStore.eventsMatchingPredicate(predicate); + + final count = eventsArray.count; + List fetchedEvents = []; + for (int i = 0; i < count; i++) { + final obj = eventsArray.objectAtIndex(i); + final event = EKEvent.as(obj); + fetchedEvents.add(event); + } + + fetchedEvents.sort((a, b) { + return a.startDate.compare(b.startDate).value; + }); + + setState(() { + _events = fetchedEvents.map((e) { + final title = e.title.toDartString(); + final dt = e.startDate.toDateTime(); + return { + 'title': title, + 'date': + '${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')}', + 'time': + '${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}', + }; + }).toList(); + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('EventKit Native Interop Demo')), + body: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ElevatedButton( + onPressed: _hasCalendarPermission ? null : _requestPermission, + child: Text( + _hasCalendarPermission + ? 'Calendar Access Granted' + : 'Request Calendar Permission', + ), + ), + const Divider(height: 32), + ElevatedButton( + onPressed: _hasCalendarPermission + ? () => showDialog( + context: context, + builder: (context) => CreateEventDialog( + eventStore: _eventStore, + onEventCreated: _retrieveEvents, + ), + ) + : null, + child: const Text('Create Event'), + ), + const SizedBox(height: 8), + ElevatedButton( + onPressed: _hasCalendarPermission ? _retrieveEvents : null, + child: const Text('Retrieve Events'), + ), + const SizedBox(height: 24), + const Text( + 'Events', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 8), + Expanded( + child: _events.isEmpty + ? const Center(child: Text('No events to display.')) + : SingleChildScrollView( + child: DataTable( + columns: const [ + DataColumn(label: Text('Title')), + DataColumn(label: Text('Date')), + DataColumn(label: Text('Time')), + ], + rows: _events + .map( + (event) => DataRow( + cells: [ + DataCell(Text(event['title'] ?? '')), + DataCell(Text(event['date'] ?? '')), + DataCell(Text(event['time'] ?? '')), + ], + ), + ) + .toList(), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/native_interop_demos/ios_calendar_demo/pubspec.yaml b/native_interop_demos/ios_calendar_demo/pubspec.yaml new file mode 100644 index 0000000..4f02e47 --- /dev/null +++ b/native_interop_demos/ios_calendar_demo/pubspec.yaml @@ -0,0 +1,24 @@ +name: ios_calendar_demo +description: "A new Flutter project." +publish_to: 'none' +version: 0.1.0+1 + +environment: + sdk: ^3.13.0-201.0.dev + +dependencies: + ffi: ^2.2.0 + flutter: + sdk: flutter + objective_c: ^9.4.1 + permission_handler: ^12.0.3 + swiftgen: ^0.1.2 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^6.0.0 + ffigen: ^20.1.1 + +flutter: + uses-material-design: true diff --git a/native_interop_demos/ios_calendar_demo/tool/generate_code.dart b/native_interop_demos/ios_calendar_demo/tool/generate_code.dart new file mode 100644 index 0000000..f8385bb --- /dev/null +++ b/native_interop_demos/ios_calendar_demo/tool/generate_code.dart @@ -0,0 +1,48 @@ +import 'dart:io'; + +import 'package:ffigen/ffigen.dart'; + +void main() { + print('Generating EventKit bindings directly from Objective-C headers...'); + final generator = FfiGenerator( + headers: Headers( + entryPoints: [ + Uri.file( + '/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS.sdk/System/Library/Frameworks/EventKit.framework/Headers/EventKit.h', + ), + ], + ), + objectiveC: ObjectiveC( + interfaces: Interfaces.includeSet({ + 'EKEventStore', + 'EKEvent', + 'EKCalendar', + 'EKCalendarItem', + 'EKObject', + }), + ), + enums: Enums.includeSet({'EKEntityType'}), + output: Output( + dartFile: Uri.file('lib/eventkit_bindings.dart'), + preamble: ''' +// Copyright (c) 2025, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +// Auto-generated by swiftgen +// coverage:ignore-file +''', + ), + ); + + generator.generate(); + + // Clean up old swiftgen wrapper files since they are no longer needed + final swiftFile = File('ios/Runner/eventkit_wrapper.swift'); + if (swiftFile.existsSync()) swiftFile.deleteSync(); + + final objcFileOld = File('ios/Runner/eventkit_wrapper.m'); + if (objcFileOld.existsSync()) objcFileOld.deleteSync(); + + print('Done.'); +} diff --git a/native_interop_demos/ios_calendar_demo/tool/test_decl.dart b/native_interop_demos/ios_calendar_demo/tool/test_decl.dart new file mode 100644 index 0000000..afa05f6 --- /dev/null +++ b/native_interop_demos/ios_calendar_demo/tool/test_decl.dart @@ -0,0 +1,17 @@ +import 'package:ios_calendar_demo/eventkit_bindings.dart'; +import 'package:objective_c/objective_c.dart' as objc; + +void main() { + final store = EKEventStore(); + final startDate = objc.NSDate.date(); + final endDate = objc.NSDate.dateWithTimeIntervalSinceNow(30.0 * 24 * 60 * 60); + final predicate = store.predicateForEventsWithStartDate(startDate, endDate: endDate); + final eventsArray = store.eventsMatchingPredicate(predicate); + + final count = eventsArray.count; + for (int i = 0; i < count; i++) { + final obj = eventsArray.objectAtIndex(i); + final event = EKEvent.as(obj); + final int result = event.startDate.compare(event.endDate).value; + } +} diff --git a/post-patch b/post-patch new file mode 100644 index 0000000..b00978b --- /dev/null +++ b/post-patch @@ -0,0 +1,60 @@ +# Sample workflow for building and deploying a mdBook site to GitHub Pages +# +# To get started with mdBook see: https://rust-lang.github.io/mdBook/index.html +# +name: Deploy mdBook site to Pages + +on: + # Runs on pushes targeting the default branch + push: + branches: ["main"] + + # Allows you to run this workflow manually from the Actions tab + workflow_dispatch: + +# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages +permissions: + contents: read + pages: write + id-token: write + +# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. +# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. +concurrency: + group: "pages" + cancel-in-progress: false + +jobs: + # Build job + build: + runs-on: ubuntu-latest + env: + MDBOOK_VERSION: 0.4.36 + steps: + - uses: actions/checkout@v4 + - name: Install mdBook + run: | + curl --proto '=https' --tlsv1.2 https://sh.rustup.rs -sSf -y | sh + rustup update + cargo install --version ${MDBOOK_VERSION} mdbook + - name: Setup Pages + id: pages + uses: actions/configure-pages@v5 + - name: Build with mdBook + run: mdbook build + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + path: ./book + + # Deployment job + deploy: + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + needs: build + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v5