diff --git a/packages/flutter_order_details/.gitignore b/packages/flutter_order_details/.gitignore new file mode 100644 index 0000000..e31020f --- /dev/null +++ b/packages/flutter_order_details/.gitignore @@ -0,0 +1,49 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ +migrate_working_dir/ +.metadata + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.packages +.pub-cache/ +.pub/ +/build/ +pubspec.lock + +# 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 + +# env +*dotenv diff --git a/packages/flutter_order_details/CONTRIBUTING.md b/packages/flutter_order_details/CONTRIBUTING.md new file mode 100644 index 0000000..b8bb72a --- /dev/null +++ b/packages/flutter_order_details/CONTRIBUTING.md @@ -0,0 +1,195 @@ +# Contributing +First off, thanks for taking the time to contribute! ❤️ + +All types of contributions are encouraged and valued. +See the [Table of Contents](#table-of-contents) for different ways to help and details about how we handle them. +Please make sure to read the relevant section before making your contribution. +It will make it a lot easier for us maintainers and smooth out the experience for all involved. +Iconica looks forward to your contributions. 🎉 + +## Table of contents +- [Contributing](#contributing) + - [Table of contents](#table-of-contents) + - [Code of conduct](#code-of-conduct) + - [Legal notice](#legal-notice) + - [I have a question](#i-have-a-question) + - [I want to contribute](#i-want-to-contribute) + - [Reporting bugs](#reporting-bugs) + - [Contributing code](#contributing-code) + +## Code of conduct + +### Legal notice +When contributing to this project, you must agree that you have authored 100% of the content, that you have the necessary rights to the content and that the content you contribute may be provided under the project license. +All accepted pull requests and other additions to this project will be considered intellectual property of Iconica. + +All repositories should be kept clean of jokes, easter eggs and other unnecessary additions. + +## I have a question + +If you want to ask a question, we assume that you have read the available documentation found within the code. +Before you ask a question, it is best to search for existing issues that might help you. +In case you have found a suitable issue but still need clarification, you can ask your question +It is also advisable to search the internet for answers first. + +If you then still feel the need to ask a question and need clarification, we recommend the following: + +- Open an issue. +- Provide as much context as you can about what you're running into. + +We will then take care of the issue as soon as possible. + +## I want to contribute + +### Reporting bugs + + +**Before submitting a bug report** + +A good bug report shouldn't leave others needing to chase you up for more information. +Therefore, we ask you to investigate carefully, collect information and describe the issue in detail in your report. +Please complete the following steps in advance to help us fix any potential bug as fast as possible. + +- Make sure that you are using the latest version. +- Determine if your bug is really a bug and not an error on your side e.g. using incompatible environment components/versions (If you are looking for support, you might want to check [this section](#i-have-a-question)). +- To see if other users have experienced (and potentially already solved) the same issue you are having, check if there is not already a bug report existing for your bug or error. +- Also make sure to search the internet (including Stack Overflow) to see if users outside of Iconica have discussed the issue. +- Collect information about the bug: +- Stack trace (Traceback) +- OS, Platform and Version (Windows, Linux, macOS, x86, ARM) +- Version of the interpreter, compiler, SDK, runtime environment, package manager, depending on what seems relevant. +- Time and date of occurrence +- Describe the expected result and actual result +- Can you reliably reproduce the issue? And can you also reproduce it with older versions? Describe all steps that lead to the bug. + +Once it's filed: + +- The project team will label the issue accordingly. +- A team member will try to reproduce the issue with your provided steps. + If there are no reproduction steps or no obvious way to reproduce the issue, the team will ask you for additional information. +- If the team is able to reproduce the issue, it will be moved into the backlog, as well as marked with a priority, and the issue will be left to be [implemented by someone](#contributing-code). + +### Contributing code + +When you start working on your contribution, make sure you are aware of the relevant documentation and the functionality of the component you are working on. + +When writing code, follow the style guidelines set by Dart: [Effective Dart](https://Dart.dev/guides/language/effective-Dart). This contains most information you will need to write clean Dart code that is well documented. + +**Documentation** + +As Effective Dart indicates, documenting your public methods with Dart doc comments is recommended. +Aside from Effective Dart, we require specific information in the documentation of a method: + +At the very least, your documentation should first name what the code does, then followed below by requirements for calling the method, the result of the method. +Any references to internal variables or other methods should be done through [var] to indicate a reference. + +If the method or class is complex enough (determined by the reviewers) an example is required. +If unsure, add an example in the docs using code blocks. + +For classes and methods, document the individual parameters with their implications. + +> Tip: Remember that the shortest documentation can be written by having good descriptive names in the first place. + +An example: +```Dart +library iconica_utilities.bidirectional_sorter; + +part 'sorter.Dart'; +part 'enum.Dart'; + +/// Generic sort method, allow sorting of list with primitives or complex types. +/// Uses [SortDirection] to determine the direction, either Ascending or Descending, +/// Gets called on [List] toSort of type [T] which cannot be shorter than 2. +/// Optionally for complex types a [Comparable] [Function] can be given to compare complex types. +/// ``` +/// List objects = []; +/// for (int i = 0; i < 10; i++) { +/// objects.add(TestObject(name: "name", id: i)); +/// } +/// +/// sort( +/// SortDirection.descending, objects, (object) => object.id); +/// +/// ``` +/// In the above example a list of TestObjects is created, and then sorted in descending order. +/// If the implementation of TestObject is as following: +/// ``` +/// class TestObject { +/// final String name; +/// final int id; +/// +/// TestObject({required this.name, required this.id}); +/// } +/// ``` +/// And the list is logged to the console, the following will appear: +/// ``` +/// [name9, name8, name7, name6, name5, name4, name3, name2, name1, name0] +/// ``` + +void sort( + /// Determines the sorting direction, can be either Ascending or Descending + SortDirection sortDirection, + + /// Incoming list, which gets sorted + List toSort, [ + + /// Optional comparable, which is only necessary for complex types + SortFieldGetter? sortValueCallback, +]) { + if (toSort.length < 2) return; + assert( + toSort.whereType().isNotEmpty || sortValueCallback != null); + BidirectionalSorter( + sortInstructions: >[ + SortInstruction( + sortValueCallback ?? (t) => t as Comparable, sortDirection), + ], + ).sort(toSort); +} + +/// same functionality as [sort] but with the added functionality +/// of sorting multiple values +void sortMulti( + /// Incoming list, which gets sorted + List toSort, + + /// list of comparables to sort multiple values at once, + /// priority based on index + List> sortValueCallbacks, +) { + if (toSort.length < 2) return; + assert(sortValueCallbacks.isNotEmpty); + BidirectionalSorter( + sortInstructions: sortValueCallbacks, + ).sort(toSort); +} + +``` + +**Tests** + +For each public method that was created, excluding widgets, which contains any form of logic (e.g. Calculations, predicates or major side-effects) tests are required. + +A set of tests is written for each method, covering at least each path within the method. For example: + +```Dart +void foo() { + try { + var bar = doSomething(); + if (bar) { + doSomethingElse(); + } else { + doSomethingCool(); + } + } catch (_) { + displayError(); + } +} +``` +The method above should result in 3 tests: + +1. A test for the path leading to displayError by the cause of an exception +2. A test for if bar is true, resulting in doSomethingElse() +3. A test for if bar is false, resulting in the doSomethingCool() method being called. + +This means that we require 100% coverage of each method you test. diff --git a/packages/flutter_order_details/LICENSE b/packages/flutter_order_details/LICENSE new file mode 100644 index 0000000..4c21bd7 --- /dev/null +++ b/packages/flutter_order_details/LICENSE @@ -0,0 +1,9 @@ +Copyright (c) 2024 Iconica, All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + + Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. + Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. + Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. \ No newline at end of file diff --git a/packages/flutter_order_details/README.md b/packages/flutter_order_details/README.md new file mode 100644 index 0000000..b736730 --- /dev/null +++ b/packages/flutter_order_details/README.md @@ -0,0 +1,36 @@ +# flutter_order_details + +This component contains TODO... + +## Features + +* TODO... + +## Usage + +First, TODO... + +For a more detailed example you can see the [example](https://github.com/Iconica-Development/flutter_order_details/tree/main/example). + +Or, you could run the example yourself: +``` +git clone https://github.com/Iconica-Development/flutter_order_details.git + +cd flutter_order_details + +cd example + +flutter run +``` + +## Issues + +Please file any issues, bugs or feature request as an issue on our [GitHub](https://github.com/Iconica-Development/flutter_order_details) page. Commercial support is available if you need help with integration with your app or services. You can contact us at [support@iconica.nl](mailto:support@iconica.nl). + +## Want to contribute + +If you would like to contribute to the component (e.g. by improving the documentation, solving a bug or adding a cool new feature), please carefully review our [contribution guide](./CONTRIBUTING.md) and send us your [pull request](https://github.com/Iconica-Development/flutter_order_details/pulls). + +## Author + +This flutter_order_details for Flutter is developed by [Iconica](https://iconica.nl). You can contact us at diff --git a/packages/flutter_order_details/analysis_options.yaml b/packages/flutter_order_details/analysis_options.yaml new file mode 100644 index 0000000..0736605 --- /dev/null +++ b/packages/flutter_order_details/analysis_options.yaml @@ -0,0 +1,9 @@ +include: package:flutter_iconica_analysis/components_options.yaml + +# Possible to overwrite the rules from the package + +analyzer: + exclude: + +linter: + rules: diff --git a/packages/flutter_order_details/example/.gitignore b/packages/flutter_order_details/example/.gitignore new file mode 100644 index 0000000..8760a85 --- /dev/null +++ b/packages/flutter_order_details/example/.gitignore @@ -0,0 +1,53 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ +.metadata +pubspec.lock + +# 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 + +# Platforms +/android/ +/ios/ +/linux/ +/macos/ +/web/ +/windows/ diff --git a/packages/flutter_order_details/example/README.md b/packages/flutter_order_details/example/README.md new file mode 100644 index 0000000..2b3fce4 --- /dev/null +++ b/packages/flutter_order_details/example/README.md @@ -0,0 +1,16 @@ +# example + +A new Flutter project. + +## Getting Started + +This project is a starting point for a Flutter application. + +A few resources to get you started if this is your first Flutter project: + +- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab) +- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook) + +For help getting started with Flutter development, view the +[online documentation](https://docs.flutter.dev/), which offers tutorials, +samples, guidance on mobile development, and a full API reference. diff --git a/packages/flutter_order_details/example/analysis_options.yaml b/packages/flutter_order_details/example/analysis_options.yaml new file mode 100644 index 0000000..31b4b51 --- /dev/null +++ b/packages/flutter_order_details/example/analysis_options.yaml @@ -0,0 +1,9 @@ +include: package:flutter_iconica_analysis/analysis_options.yaml + +# Possible to overwrite the rules from the package + +analyzer: + exclude: + +linter: + rules: diff --git a/packages/flutter_order_details/example/lib/config/order_detail_screen_configuration.dart b/packages/flutter_order_details/example/lib/config/order_detail_screen_configuration.dart new file mode 100644 index 0000000..6a04536 --- /dev/null +++ b/packages/flutter_order_details/example/lib/config/order_detail_screen_configuration.dart @@ -0,0 +1,144 @@ +import "package:flutter/material.dart"; +import "package:flutter_order_details/flutter_order_details.dart"; + +OrderDetailConfiguration getOrderDetailConfiguration() => + OrderDetailConfiguration( + // (REQUIRED): onComplete function that allows you to do with the + // results as wanted. + // ignore: avoid_print + onCompleted: (OrderResult result) => print(result.order), + + // (REQUIRED): List of steps that the user has to go through to + // complete the order. + steps: [ + OrderDetailStep( + // stepName: "Step 1", + formKey: GlobalKey(), + fields: [ + OrderChoiceInput( + // REQUIRED + title: "Payment method", + outputKey: "payment_method", + items: [ + "PAY NOW", + "PAY AT CASH REGISTER", + ], + + // OPTIONAL + subtitle: "Choose a payment method", + ), + ], + ), + OrderDetailStep( + // stepName: "Step 1", + formKey: GlobalKey(), + fields: [ + OrderDropdownInput( + // REQUIRED + title: "When do you want to pick up your order?", + outputKey: "order_pickup_time", + items: [ + "Today", + "Monday 7 juni", + "Tuesday 8 juni", + "Wednesday 9 juni", + "Thursday 10 juni", + ], + + // OPTIONAL + subtitle: "Choose a date", + ), + OrderTimePicker( + // REQUIRED + title: "Choose a time", + + titleStyle: OrderDetailTitleStyle.none, + padding: EdgeInsets.zero, + outputKey: "chosen_time", + + // OPTIONAL + morningLabel: "Morning", + afternoonLabel: "Afternoon", + eveningLabel: "Evening", + + beginTime: 9, // Opening time + endTime: 20, // Closing time + ), + ], + ), + OrderDetailStep( + // stepName: "Step 1", + formKey: GlobalKey(), + fields: [ + OrderTextInput( + // REQUIRED + outputKey: "user_name", + title: "What is your name?", + textController: TextEditingController(), + + // OPTIONAL + hint: "Your name", + ), + OrderAddressInput( + // REQUIRED + outputKey: "user_address", + title: "What is your address?", + textController: TextEditingController(), + ), + ], + ), + OrderDetailStep( + // stepName: "Step 2", + formKey: GlobalKey(), + fields: [ + OrderPhoneInput( + // REQUIRED + outputKey: "user_phone", + title: "Phone", + textController: TextEditingController(), + + // OPTIONAL + errorIsRequired: "You must enter a phone number", + errorMustBe11Digits: "A phone number must be 11 digits long", + errorMustBeNumeric: "Phone number must be numeric", + errorMustStartWith316: "Phone number must start with 316", + ), + OrderEmailInput( + // REQUIRED + outputKey: "user_email", + title: "Email", + textController: TextEditingController(), + + // OPTIONAL + errorInvalidEmail: "Invalid email address", + ), + ], + ), + ], + + // (OPTIONAL) (RECOMMENDED): Custom localizations. + localization: const OrderDetailLocalization( + backButton: "Back", + nextButton: "Next", + completeButton: "Complete", + ), + + // (OPTIONAL): Progress bar + progressIndicator: true, + + // (OPTIONAL): Input field padding + inputFieldPadding: const EdgeInsets.symmetric( + horizontal: 32, + vertical: 16, + ), + + // (OPTIONAL): Title padding + titlePadding: const EdgeInsets.only(left: 16, right: 16, top: 16), + + // (OPTIONAL): App bar + appBar: AppBar( + title: const Text( + "Order Details", + ), + ), + ); diff --git a/packages/flutter_order_details/example/lib/main.dart b/packages/flutter_order_details/example/lib/main.dart new file mode 100644 index 0000000..59496c4 --- /dev/null +++ b/packages/flutter_order_details/example/lib/main.dart @@ -0,0 +1,20 @@ +import "package:example/config/order_detail_screen_configuration.dart"; +import "package:example/utils/theme.dart"; +import "package:flutter/material.dart"; +import "package:flutter_order_details/flutter_order_details.dart"; + +void main() => runApp(const MyApp()); + +class MyApp extends StatelessWidget { + /// + const MyApp({super.key}); + + @override + Widget build(BuildContext context) => MaterialApp( + title: "Flutter Demo", + theme: getTheme(), + home: OrderDetailScreen( + configuration: getOrderDetailConfiguration(), + ), + ); +} diff --git a/packages/flutter_order_details/example/lib/utils/theme.dart b/packages/flutter_order_details/example/lib/utils/theme.dart new file mode 100644 index 0000000..c4e60f9 --- /dev/null +++ b/packages/flutter_order_details/example/lib/utils/theme.dart @@ -0,0 +1,32 @@ +import "package:flutter/material.dart"; + +ThemeData getTheme() => ThemeData( + scaffoldBackgroundColor: const Color.fromRGBO(250, 249, 246, 1), + textTheme: const TextTheme( + labelMedium: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w400, + color: Color.fromRGBO(0, 0, 0, 1), + ), + titleMedium: TextStyle( + fontSize: 16, + color: Color.fromRGBO(60, 60, 59, 1), + fontWeight: FontWeight.w700, + ), + ), + inputDecorationTheme: const InputDecorationTheme( + fillColor: Color.fromRGBO(255, 255, 255, 1), + ), + colorScheme: const ColorScheme.light( + primary: Color.fromRGBO(64, 87, 122, 1), + secondary: Color.fromRGBO(255, 255, 255, 1), + surface: Color.fromRGBO(250, 249, 246, 1), + ), + appBarTheme: const AppBarTheme( + backgroundColor: Color.fromRGBO(64, 87, 122, 1), + titleTextStyle: TextStyle( + fontSize: 28, + color: Color.fromRGBO(255, 255, 255, 1), + ), + ), + ); diff --git a/packages/flutter_order_details/example/pubspec.yaml b/packages/flutter_order_details/example/pubspec.yaml new file mode 100644 index 0000000..817c9cf --- /dev/null +++ b/packages/flutter_order_details/example/pubspec.yaml @@ -0,0 +1,33 @@ +name: example +description: "Demonstrates how to use the flutter_order_details package." +publish_to: 'none' +version: 1.0.0+1 + +environment: + sdk: '>=3.3.0 <4.0.0' + +dependencies: + flutter: + sdk: flutter + flutter_order_details: + path: ../ + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_iconica_analysis: + git: + url: https://github.com/Iconica-Development/flutter_iconica_analysis + ref: 7.0.0 + +flutter: + uses-material-design: true + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic diff --git a/packages/flutter_order_details/lib/flutter_order_details.dart b/packages/flutter_order_details/lib/flutter_order_details.dart new file mode 100644 index 0000000..0694d28 --- /dev/null +++ b/packages/flutter_order_details/lib/flutter_order_details.dart @@ -0,0 +1,17 @@ +/// Flutter component for shopping cart. +library flutter_order_details; + +export "src/configuration/order_detail_configuration.dart"; +export "src/configuration/order_detail_localization.dart"; +export "src/configuration/order_detail_step.dart"; +export "src/configuration/order_detail_title_style.dart"; +export "src/models/order_address_input.dart"; +export "src/models/order_choice_input.dart"; +export "src/models/order_dropdown_input.dart"; +export "src/models/order_email_input.dart"; +export "src/models/order_input.dart"; +export "src/models/order_phone_input.dart"; +export "src/models/order_result.dart"; +export "src/models/order_text_input.dart"; +export "src/models/order_time_picker_input.dart"; +export "src/widgets/order_detail_screen.dart"; diff --git a/packages/flutter_order_details/lib/src/configuration/order_detail_configuration.dart b/packages/flutter_order_details/lib/src/configuration/order_detail_configuration.dart new file mode 100644 index 0000000..1aaa75f --- /dev/null +++ b/packages/flutter_order_details/lib/src/configuration/order_detail_configuration.dart @@ -0,0 +1,50 @@ +import "package:flutter/widgets.dart"; +import "package:flutter_order_details/src/configuration/order_detail_localization.dart"; +import "package:flutter_order_details/src/configuration/order_detail_step.dart"; +import "package:flutter_order_details/src/models/order_result.dart"; + +/// Configuration for the order detail screen. +class OrderDetailConfiguration { + /// Constructor for the order detail configuration. + const OrderDetailConfiguration({ + required this.steps, + // + required this.onCompleted, + // + this.progressIndicator = true, + // + this.localization = const OrderDetailLocalization(), + // + this.inputFieldPadding = const EdgeInsets.symmetric( + horizontal: 32, + vertical: 16, + ), + this.titlePadding = const EdgeInsets.only(left: 16, right: 16, top: 16), + // + this.appBar, + }); + + /// The different steps that the user has to go through to complete the order. + /// Each step contains a list of fields that the user has to fill in. + final List steps; + + /// Callback function that is called when the user has completed the order. + /// The result of the order is passed as an argument to the function. + final Function(OrderResult result) onCompleted; + + /// Whether or not you want to show a progress indicator at + /// the top of the screen. + final bool progressIndicator; + + /// Localization for the order detail screen. + final OrderDetailLocalization localization; + + /// Padding around the input fields. + final EdgeInsets inputFieldPadding; + + /// Padding around the title of the input fields. + final EdgeInsets titlePadding; + + /// Optional app bar that you can pass to the order detail screen. + final PreferredSizeWidget? appBar; +} diff --git a/packages/flutter_order_details/lib/src/configuration/order_detail_localization.dart b/packages/flutter_order_details/lib/src/configuration/order_detail_localization.dart new file mode 100644 index 0000000..f339d3a --- /dev/null +++ b/packages/flutter_order_details/lib/src/configuration/order_detail_localization.dart @@ -0,0 +1,18 @@ +/// Localizations for the order detail page. +class OrderDetailLocalization { + /// Constructor for the order detail localization. + const OrderDetailLocalization({ + this.nextButton = "Next", + this.backButton = "Back", + this.completeButton = "Complete", + }); + + /// Next button localization. + final String nextButton; + + /// Back button localization. + final String backButton; + + /// Complete button localization. + final String completeButton; +} diff --git a/packages/flutter_order_details/lib/src/configuration/order_detail_step.dart b/packages/flutter_order_details/lib/src/configuration/order_detail_step.dart new file mode 100644 index 0000000..feb0658 --- /dev/null +++ b/packages/flutter_order_details/lib/src/configuration/order_detail_step.dart @@ -0,0 +1,22 @@ +import "package:flutter/widgets.dart"; +import "package:flutter_order_details/flutter_order_details.dart"; + +/// Configuration for the order detail step. +class OrderDetailStep { + /// Constructor for the order detail step. + OrderDetailStep({ + required this.formKey, + required this.fields, + this.stepName, + }); + + /// Optional name for the step. + final String? stepName; + + /// Form key for the step. + final GlobalKey formKey; + + /// List of fields that the user has to fill in. + /// Each field must extend from the `OrderDetailInput` class. + final List fields; +} diff --git a/packages/flutter_order_details/lib/src/configuration/order_detail_title_style.dart b/packages/flutter_order_details/lib/src/configuration/order_detail_title_style.dart new file mode 100644 index 0000000..8cc7896 --- /dev/null +++ b/packages/flutter_order_details/lib/src/configuration/order_detail_title_style.dart @@ -0,0 +1,14 @@ +/// An enum to define the style of the title in the order detail. +enum OrderDetailTitleStyle { + /// The title displayed as a textlabel above the field. + text, + + /// The title displayed as a label inside the field. + /// NOTE: Not all fields support this. Such as, but not limited to: + /// - Dropdown + /// - Time Picker + label, + + /// Does not display any form of title. + none, +} diff --git a/packages/flutter_order_details/lib/src/models/formfield_error_builder.dart b/packages/flutter_order_details/lib/src/models/formfield_error_builder.dart new file mode 100644 index 0000000..b764eed --- /dev/null +++ b/packages/flutter_order_details/lib/src/models/formfield_error_builder.dart @@ -0,0 +1,25 @@ +import "package:flutter/material.dart"; + +/// Error Builder for form fields. +class FormFieldErrorBuilder extends StatelessWidget { + /// Constructor for the form field error builder. + const FormFieldErrorBuilder({ + required this.errorMessage, + super.key, + }); + + /// Error message to display. + final String errorMessage; + + @override + Widget build(BuildContext context) { + var theme = Theme.of(context); + return Text( + errorMessage, + textAlign: TextAlign.left, + style: TextStyle( + color: theme.colorScheme.error, + ), + ); + } +} diff --git a/packages/flutter_order_details/lib/src/models/order_address_input.dart b/packages/flutter_order_details/lib/src/models/order_address_input.dart new file mode 100644 index 0000000..e34f4cc --- /dev/null +++ b/packages/flutter_order_details/lib/src/models/order_address_input.dart @@ -0,0 +1,160 @@ +import "package:flutter/material.dart"; +import "package:flutter/services.dart"; +import "package:flutter_order_details/flutter_order_details.dart"; + +/// Order input for addresses with with predefined text fields and validation. +class OrderAddressInput extends OrderDetailInput { + /// Constructor of the order address input. + OrderAddressInput({ + required super.title, + required super.outputKey, + required this.textController, + super.titleStyle, + super.titleAlignment, + super.titlePadding, + super.subtitle, + super.errorIsRequired, + super.hint = "0000XX", + super.isRequired, + super.isReadOnly, + super.initialValue, + this.streetNameTitle = "Street name", + this.postalCodeTitle = "Postal code", + this.cityTitle = "City", + this.streetNameValidators, + this.postalCodeValidators, + this.cityValidators, + this.inputFormatters, + super.paddingBetweenFields = const EdgeInsets.symmetric(vertical: 4), + }); + + /// Title for the street name. + final String streetNameTitle; + + /// Title for the postal code. + final String postalCodeTitle; + + /// Title for the city. + final String cityTitle; + + /// Text Control parent that contains the value of all the other three + /// controllers. + final TextEditingController textController; + + /// Text Controller for street names. + final TextEditingController streetNameController = TextEditingController(); + + /// Text Controller for postal codes. + final TextEditingController postalCodeController = TextEditingController(); + + /// Text Controller for the city name. + final TextEditingController cityController = TextEditingController(); + + /// Validators for the street name. + final List? streetNameValidators; + + /// Validators for the postal code. + final List? postalCodeValidators; + + /// Validators for the city. + final List? cityValidators; + + /// Input formatters for the postal code. + final List? inputFormatters; + + @override + Widget build( + BuildContext context, + String? buildInitialValue, + Function({bool needsBlur}) onBlurBackground, + ) { + void setUpControllers(String address) { + var addressParts = address.split(", "); + + if (addressParts.isNotEmpty) { + streetNameController.text = addressParts[0]; + } + + if (addressParts.length > 1) { + postalCodeController.text = addressParts[1]; + } + + if (addressParts.length > 2) { + cityController.text = addressParts[2]; + } + } + + void inputChanged(String _) { + var address = "${streetNameController.text}, " + "${postalCodeController.text}, " + "${cityController.text}"; + + textController.text = address; + + currentValue = address; + onValueChanged?.call(address); + } + + textController.text = initialValue ?? buildInitialValue ?? ""; + currentValue = textController.text; + + setUpControllers(currentValue ?? ""); + + return buildOutline( + context, + [ + OrderTextInput( + title: streetNameTitle, + outputKey: "internal_street_name", + textController: streetNameController, + titleStyle: OrderDetailTitleStyle.none, + onValueChanged: inputChanged, + hint: "De Dam 1", + initialValue: streetNameController.text, + validators: streetNameValidators ?? [], + ), + OrderTextInput( + title: postalCodeTitle, + outputKey: "internal_postal_code", + textController: postalCodeController, + titleStyle: OrderDetailTitleStyle.none, + onValueChanged: inputChanged, + validators: postalCodeValidators ?? + [ + (value) { + if (value?.length != 6) { + return "Postal code must be 6 characters"; + } + return null; + }, + (value) { + if (value != null && + !RegExp(r"^\d{4}\s?[a-zA-Z]{2}$").hasMatch(value)) { + return "Postal code must be in the format 0000XX"; + } + return null; + } + ], + inputFormatters: inputFormatters ?? + [ + FilteringTextInputFormatter.allow(RegExp(r"^\d{0,4}[A-Z]*")), + LengthLimitingTextInputFormatter(6), + ], + hint: hint, + initialValue: postalCodeController.text, + ), + OrderTextInput( + title: cityTitle, + outputKey: "internal_city", + textController: cityController, + titleStyle: OrderDetailTitleStyle.none, + onValueChanged: inputChanged, + hint: "Amsterdam", + initialValue: cityController.text, + validators: cityValidators ?? [], + ), + ], + onBlurBackground, + ); + } +} diff --git a/packages/flutter_order_details/lib/src/models/order_choice_input.dart b/packages/flutter_order_details/lib/src/models/order_choice_input.dart new file mode 100644 index 0000000..f482233 --- /dev/null +++ b/packages/flutter_order_details/lib/src/models/order_choice_input.dart @@ -0,0 +1,175 @@ +import "package:flutter/material.dart"; +import "package:flutter_order_details/flutter_order_details.dart"; +import "package:flutter_order_details/src/models/formfield_error_builder.dart"; + +/// Order input for choice with predefined text fields and validation. +class OrderChoiceInput extends OrderDetailInput { + /// Constructor of the order choice input. + OrderChoiceInput({ + required super.title, + required super.outputKey, + required this.items, + super.titleStyle, + super.titleAlignment, + super.titlePadding, + super.subtitle, + super.errorIsRequired, + super.isRequired, + super.isReadOnly, + super.initialValue, + this.fieldHeight = 140, + this.fieldPadding = const EdgeInsets.symmetric( + horizontal: 4, + vertical: 64, + ), + this.paddingBetweenFields = const EdgeInsets.symmetric(vertical: 12), + }); + + /// Items to show within the dropdown menu. + final List items; + + /// Padding for the field. + final EdgeInsets fieldPadding; + + /// Padding between fields. + @override + // ignore: overridden_fields + final EdgeInsets paddingBetweenFields; + + /// The height of the input field. + final double fieldHeight; + + final _ChoiceNotifier _notifier = _ChoiceNotifier(); + + @override + Widget build( + BuildContext context, + String? buildInitialValue, + Function({bool needsBlur}) onBlurBackground, + ) { + void onItemChanged(String value) { + if (value == currentValue) { + currentValue = null; + onValueChanged?.call(""); + _notifier.setValue(""); + } else { + currentValue = value; + onValueChanged?.call(value); + _notifier.setValue(value); + } + } + + return buildOutline( + context, + ListenableBuilder( + listenable: _notifier, + builder: (context, child) => _ChoiceInputField( + currentValue: currentValue ?? initialValue ?? buildInitialValue ?? "", + items: items, + onTap: onItemChanged, + validate: validate, + fieldPadding: fieldPadding, + paddingBetweenFields: paddingBetweenFields, + ), + ), + onBlurBackground, + ); + } +} + +class _ChoiceNotifier extends ChangeNotifier { + String? _value; + + String? get value => _value; + + void setValue(String value) { + _value = value; + notifyListeners(); + } +} + +class _ChoiceInputField extends FormField { + _ChoiceInputField({ + required T currentValue, + required List items, + required Function(T) onTap, + required String? Function(T?) validate, + required EdgeInsets fieldPadding, + required EdgeInsets paddingBetweenFields, + super.key, + }) : super( + validator: (value) => validate(currentValue), + builder: (FormFieldState field) => Padding( + padding: fieldPadding, + child: Column( + children: [ + for (var item in items) ...[ + Padding( + padding: paddingBetweenFields, + child: _InputContent( + i: item, + currentValue: currentValue, + onTap: onTap, + ), + ), + ], + if (field.hasError) ...[ + FormFieldErrorBuilder(errorMessage: field.errorText!), + ], + ], + ), + ), + ); +} + +class _InputContent extends StatelessWidget { + const _InputContent({ + required this.i, + required this.currentValue, + required this.onTap, + }); + + final T i; + final T currentValue; + final Function(T) onTap; + + @override + Widget build(BuildContext context) { + var theme = Theme.of(context); + + var boxDecoration = BoxDecoration( + color: currentValue == i.toString() + ? theme.colorScheme.primary + : theme.colorScheme.secondary, + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: theme.colorScheme.primary, + width: 1, + ), + ); + + var decoratedBox = Container( + decoration: boxDecoration, + width: double.infinity, + height: 150, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + i.toString(), + style: theme.textTheme.labelLarge?.copyWith( + color: currentValue == i.toString() + ? theme.colorScheme.onPrimary + : theme.colorScheme.primary, + ), + ), + ], + ), + ); + + return GestureDetector( + onTap: () => onTap(i), + child: decoratedBox, + ); + } +} diff --git a/packages/flutter_order_details/lib/src/models/order_dropdown_input.dart b/packages/flutter_order_details/lib/src/models/order_dropdown_input.dart new file mode 100644 index 0000000..640099e --- /dev/null +++ b/packages/flutter_order_details/lib/src/models/order_dropdown_input.dart @@ -0,0 +1,153 @@ +import "package:flutter/material.dart"; +import "package:flutter_order_details/flutter_order_details.dart"; + +/// Order Detail input for a dropdown input. +class OrderDropdownInput extends OrderDetailInput { + /// Constructor for the order dropdown input. + OrderDropdownInput({ + required super.title, + required super.outputKey, + required this.items, + super.titleStyle, + super.titleAlignment, + super.titlePadding, + super.subtitle, + super.errorIsRequired, + super.isRequired = true, + super.isReadOnly, + super.initialValue, + this.blurOnInteraction = true, + }); + + /// Items to show within the dropdown menu. + final List items; + + /// Whether or not the screen should blur when interacting. + final bool blurOnInteraction; + + @override + Widget build( + BuildContext context, + T? buildInitialValue, + Function({bool needsBlur}) onBlurBackground, + ) { + var theme = Theme.of(context); + + void onItemChanged(T? value) { + currentValue = value; + onValueChanged?.call(value as T); + onBlurBackground(needsBlur: false); + } + + void onPopupOpen() { + if (blurOnInteraction) + onBlurBackground( + needsBlur: true, + ); + } + + var inputDecoration = InputDecoration( + labelText: titleStyle == OrderDetailTitleStyle.label ? title : null, + hintText: hint, + filled: true, + fillColor: theme.inputDecorationTheme.fillColor, + border: InputBorder.none, + ); + + currentValue = + currentValue ?? initialValue ?? buildInitialValue ?? items[0]; + + return buildOutline( + context, + DropdownButtonFormField( + value: currentValue ?? initialValue ?? buildInitialValue ?? items[0], + selectedItemBuilder: (context) => items + .map( + (item) => Text( + item.toString(), + style: theme.textTheme.labelMedium, + ), + ) + .toList(), + items: items + .map( + (item) => DropdownMenuItem( + value: item, + child: _DropdownButtonBuilder( + item: item, + currentValue: currentValue, + ), + ), + ) + .toList(), + onChanged: onItemChanged, + onTap: onPopupOpen, + style: theme.textTheme.labelMedium, + decoration: inputDecoration, + borderRadius: BorderRadius.circular(10), + icon: const Icon(Icons.keyboard_arrow_down_sharp), + validator: super.validate, + ), + onBlurBackground, + ); + } +} + +class _DropdownButtonBuilder extends StatelessWidget { + const _DropdownButtonBuilder({ + required this.item, + this.currentValue, + super.key, + }); + + final T item; + final T? currentValue; + + @override + Widget build(BuildContext context) { + var theme = Theme.of(context); + + var textBuilder = Align( + alignment: Alignment.centerLeft, + child: Padding( + padding: const EdgeInsets.all(8), + child: Text( + item.toString(), + style: theme.textTheme.labelMedium?.copyWith( + color: item == currentValue ? theme.colorScheme.onPrimary : null, + fontWeight: FontWeight.w500, + ), + ), + ), + ); + + var selectedIcon = Align( + alignment: Alignment.centerRight, + child: Padding( + padding: const EdgeInsets.all(8), + child: Icon( + Icons.check, + color: theme.colorScheme.onPrimary, + ), + ), + ); + + return DecoratedBox( + decoration: BoxDecoration( + color: item == currentValue ? theme.colorScheme.primary : null, + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: theme.colorScheme.primary, + ), + ), + child: Stack( + children: [ + textBuilder, + if (currentValue == item) ...[ + selectedIcon, + ], + ], + ), + ); + } +} diff --git a/packages/flutter_order_details/lib/src/models/order_email_input.dart b/packages/flutter_order_details/lib/src/models/order_email_input.dart new file mode 100644 index 0000000..a2bf053 --- /dev/null +++ b/packages/flutter_order_details/lib/src/models/order_email_input.dart @@ -0,0 +1,75 @@ +import "package:flutter/material.dart"; +import "package:flutter_order_details/flutter_order_details.dart"; + +/// Order Email input with predefined validators. +class OrderEmailInput extends OrderDetailInput { + /// Constructor of the order email input. + OrderEmailInput({ + required super.title, + required super.outputKey, + required this.textController, + super.titleStyle, + super.titleAlignment, + super.titlePadding, + super.subtitle, + super.hint, + super.errorIsRequired, + super.isRequired, + super.isReadOnly, + super.initialValue, + this.errorInvalidEmail = "Invalid email ( your_name@example.com )", + }) : super( + validators: [ + (value) { + if (value != null && !RegExp(r"^\w+@\w+\.\w+$").hasMatch(value)) { + return errorInvalidEmail; + } + return null; + }, + ], + ); + + /// Text Controller for email input. + final TextEditingController textController; + + /// Error message for invalid email. + final String errorInvalidEmail; + + @override + Widget build( + BuildContext context, + String? buildInitialValue, + Function({bool needsBlur}) onBlurBackground, + ) { + var theme = Theme.of(context); + + textController.text = initialValue ?? buildInitialValue ?? ""; + currentValue = textController.text; + + return buildOutline( + context, + TextFormField( + style: theme.textTheme.labelMedium, + controller: textController, + onChanged: (String value) { + currentValue = value; + super.onValueChanged?.call(value); + }, + decoration: InputDecoration( + labelText: titleStyle == OrderDetailTitleStyle.label ? title : null, + hintText: hint, + filled: true, + fillColor: theme.inputDecorationTheme.fillColor, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide.none, + ), + ), + validator: (value) => super.validate(value), + keyboardType: TextInputType.emailAddress, + readOnly: isReadOnly, + ), + onBlurBackground, + ); + } +} diff --git a/packages/flutter_order_details/lib/src/models/order_input.dart b/packages/flutter_order_details/lib/src/models/order_input.dart new file mode 100644 index 0000000..d3ac019 --- /dev/null +++ b/packages/flutter_order_details/lib/src/models/order_input.dart @@ -0,0 +1,173 @@ +import "package:flutter/material.dart"; +import "package:flutter_order_details/src/configuration/order_detail_title_style.dart"; + +/// Abstract class for order detail input. +/// Each input field must extend from this class. +abstract class OrderDetailInput { + /// Constructor for the order detail input. + OrderDetailInput({ + required this.title, + required this.outputKey, + this.titleStyle = OrderDetailTitleStyle.text, + this.titleAlignment = Alignment.centerLeft, + this.titlePadding = const EdgeInsets.symmetric(vertical: 4), + this.subtitle, + this.isRequired = true, + this.isReadOnly = false, + this.initialValue, + this.validators = const [], + this.onValueChanged, + this.hint, + this.errorIsRequired = "This field is required", + this.paddingBetweenFields = const EdgeInsets.symmetric(vertical: 4), + }); + + /// Title of the input field. + final String title; + + /// Subtitle of the input field. + final String? subtitle; + + /// The styling for the title. + final OrderDetailTitleStyle titleStyle; + + /// The alignment of the titl + final Alignment titleAlignment; + + /// Padding around the title. + final EdgeInsets titlePadding; + + /// The output key of the input field. + final String outputKey; + + /// Hint message of the input field. + final String? hint; + + /// Determines if the input field is required. + final bool isRequired; + + /// Error message for when an user does not insert something in the field + /// even though it is required. + final String errorIsRequired; + + /// A read-only field that users cannot change. + final bool isReadOnly; + + /// An initial value for the input field. This is ideal incombination + /// with the [isReadOnly] field. + final T? initialValue; + + /// Internal current value. Do not use. + T? currentValue; + + /// List of validators that should be executed when the input field + /// is validated. + List validators; + + /// Function that is called when the value of the input field changes. + final Function(T)? onValueChanged; + + /// Padding between the fields. + final EdgeInsets paddingBetweenFields; + + /// Allows you to update the current value. + @protected + set updateValue(T value) { + currentValue = value; + } + + /// Function that validates the input field. Automatically keeps track + /// of the [isRequired] keys and all the custom validators. + @protected + String? validate(T? value) { + if (isRequired && (value == null || value.toString().isEmpty)) { + return errorIsRequired; + } + + for (var validator in validators) { + var error = validator(value); + if (error != null) { + return error; + } + } + + return null; + } + + /// Builds the basic outline of an input field. + @protected + Widget buildOutline( + BuildContext context, + // ignore: avoid_annotating_with_dynamic + dynamic child, + Function({bool needsBlur}) onBlurBackground, + ) { + var theme = Theme.of(context); + + return Column( + children: [ + if (titleStyle == OrderDetailTitleStyle.text) ...[ + Align( + alignment: titleAlignment, + child: Padding( + padding: titlePadding, + child: Text( + title, + style: theme.textTheme.titleMedium, + ), + ), + ), + if (subtitle != null) ...[ + Padding( + padding: titlePadding, + child: Align( + alignment: titleAlignment, + child: Text( + subtitle!, + style: theme.textTheme.titleSmall, + ), + ), + ), + ], + ], + if (child is FormField || child is Widget) ...[ + child, + ] else if (child is List) ...[ + Column( + children: child + .map( + (FormField field) => Padding( + padding: paddingBetweenFields, + child: field, + ), + ) + .toList(), + ), + ] else if (child is List) ...[ + Column( + children: child + .map( + (OrderDetailInput input) => Padding( + padding: paddingBetweenFields, + child: input.build( + context, + input.initialValue, + onBlurBackground, + ), + ), + ) + .toList(), + ), + ], + ], + ); + } + + /// Abstract build function that each orderinput class must implement + /// themsleves. For a basic layout, they can use the [buildOutline] function. + Widget build( + BuildContext context, + T? buildInitialValue, + Function({bool needsBlur}) onBlurBackground, + ); +} diff --git a/packages/flutter_order_details/lib/src/models/order_phone_input.dart b/packages/flutter_order_details/lib/src/models/order_phone_input.dart new file mode 100644 index 0000000..f6afb3a --- /dev/null +++ b/packages/flutter_order_details/lib/src/models/order_phone_input.dart @@ -0,0 +1,101 @@ +import "package:flutter/material.dart"; +import "package:flutter/services.dart"; +import "package:flutter_order_details/flutter_order_details.dart"; + +/// Order input for phone numbers with with predefined +/// text fields and validation. +class OrderPhoneInput extends OrderDetailInput { + /// Constructor for the phone input. + OrderPhoneInput({ + required super.title, + required super.outputKey, + required this.textController, + this.errorMustBe11Digits = "Number must be 11 digits (+31 6 XXXX XXXX)", + this.errorMustStartWith316 = "Number must start with +316", + this.errorMustBeNumeric = "Number must be numeric", + super.errorIsRequired, + super.subtitle, + super.titleAlignment, + super.titlePadding, + super.titleStyle, + super.isRequired, + super.isReadOnly, + super.initialValue, + }) : super( + validators: [ + (value) { + if (value != null && value.length != 11) { + return errorMustBe11Digits; + } + return null; + }, + (value) { + if (value != null && !value.startsWith("316")) { + return errorMustStartWith316; + } + return null; + }, + (value) { + if (value != null && !RegExp(r"^\d+$").hasMatch(value)) { + return errorMustBeNumeric; + } + return null; + }, + ], + ); + + /// Text Controller for phone input. + final TextEditingController textController; + + /// Error message that notifies the number must be 11 digits long. + final String errorMustBe11Digits; + + /// Error message that notifies the number must start with +316 + final String errorMustStartWith316; + + /// Error message that notifies the number must be numeric. + final String errorMustBeNumeric; + + @override + Widget build( + BuildContext context, + String? buildInitialValue, + Function({bool needsBlur}) onBlurBackground, + ) { + var theme = Theme.of(context); + + textController.text = initialValue ?? buildInitialValue ?? "31"; + currentValue = textController.text; + + return buildOutline( + context, + TextFormField( + style: theme.textTheme.labelMedium, + controller: textController, + onChanged: (String value) { + currentValue = value; + super.onValueChanged?.call(value); + }, + decoration: InputDecoration( + labelText: titleStyle == OrderDetailTitleStyle.label ? title : null, + prefixText: "+", + prefixStyle: theme.textTheme.labelMedium, + hintText: hint, + filled: true, + fillColor: theme.inputDecorationTheme.fillColor, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide.none, + ), + ), + validator: (value) => super.validate(value), + readOnly: isReadOnly, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + LengthLimitingTextInputFormatter(11), // international phone number + ], + ), + onBlurBackground, + ); + } +} diff --git a/packages/flutter_order_details/lib/src/models/order_result.dart b/packages/flutter_order_details/lib/src/models/order_result.dart new file mode 100644 index 0000000..20a4c00 --- /dev/null +++ b/packages/flutter_order_details/lib/src/models/order_result.dart @@ -0,0 +1,14 @@ +/// OrderResult model. +/// When an user completes the field and presses the complete button, +/// the `onComplete` method returns an instance of this class that contains +/// all the developer-specified `outputKey`s and the value that was provided +/// by the user. +class OrderResult { + /// Constructor of the order result class. + OrderResult({ + required this.order, + }); + + /// Map of `outputKey`s and their respected values. + final Map order; +} diff --git a/packages/flutter_order_details/lib/src/models/order_text_input.dart b/packages/flutter_order_details/lib/src/models/order_text_input.dart new file mode 100644 index 0000000..5967160 --- /dev/null +++ b/packages/flutter_order_details/lib/src/models/order_text_input.dart @@ -0,0 +1,71 @@ +import "package:flutter/material.dart"; +import "package:flutter/services.dart"; +import "package:flutter_order_details/flutter_order_details.dart"; + +/// Default text input for order details. +class OrderTextInput extends OrderDetailInput { + /// Default text input for order details. + OrderTextInput({ + required super.title, + required super.outputKey, + required this.textController, + super.titleStyle, + super.titleAlignment, + super.titlePadding, + super.subtitle, + super.isRequired, + super.isReadOnly, + super.initialValue, + super.validators, + super.onValueChanged, + super.errorIsRequired, + super.hint, + this.inputFormatters = const [], + }); + + /// Text Controller for the input field. + final TextEditingController textController; + + /// List of input formatters for the text field. + final List inputFormatters; + + @override + Widget build( + BuildContext context, + String? buildInitialValue, + Function({bool needsBlur}) onBlurBackground, + ) { + var theme = Theme.of(context); + + textController.text = initialValue ?? buildInitialValue ?? ""; + currentValue = textController.text; + + return buildOutline( + context, + TextFormField( + style: theme.textTheme.labelMedium, + controller: textController, + onChanged: (String value) { + currentValue = value; + super.onValueChanged?.call(value); + }, + decoration: InputDecoration( + labelText: titleStyle == OrderDetailTitleStyle.label ? title : null, + hintText: hint, + filled: true, + fillColor: theme.inputDecorationTheme.fillColor, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide.none, + ), + ), + validator: super.validate, + readOnly: isReadOnly, + inputFormatters: [ + ...inputFormatters, + ], + ), + onBlurBackground, + ); + } +} diff --git a/packages/flutter_order_details/lib/src/models/order_time_picker_input.dart b/packages/flutter_order_details/lib/src/models/order_time_picker_input.dart new file mode 100644 index 0000000..b8ef05f --- /dev/null +++ b/packages/flutter_order_details/lib/src/models/order_time_picker_input.dart @@ -0,0 +1,353 @@ +import "package:flutter/material.dart"; +import "package:flutter_order_details/flutter_order_details.dart"; +import "package:flutter_order_details/src/models/formfield_error_builder.dart"; + +/// Order time picker input with predefined text fields and validation. +class OrderTimePicker extends OrderDetailInput { + /// Constructor for the time picker. + OrderTimePicker({ + required super.title, + required super.outputKey, + super.titleStyle, + super.titleAlignment, + super.titlePadding, + super.subtitle, + super.isRequired, + super.initialValue, + super.validators, + super.onValueChanged, + super.errorIsRequired, + super.hint, + this.beginTime = 9, + this.endTime = 17, + this.interval = 0.25, + this.morningLabel = "Morning", + this.afternoonLabel = "Afternoon", + this.eveningLabel = "Evening", + this.padding = const EdgeInsets.only(top: 12, bottom: 20.0), + }) : assert( + beginTime < endTime, + "Begin time cannot be greater than end time", + ); + + /// Minimum time of times to show. For example 9 (for 9AM). + final double beginTime; + + /// Final time to show. For example 17 (for 5PM). + final double endTime; + + /// For each interval a button gets generated within the begin time and + /// the end time. For example 0.25 (for ever 15 minutes). + final double interval; + + /// Translation for morning texts. + final String morningLabel; + + /// Translation for afternoon texts. + final String afternoonLabel; + + /// Translation for evening texts. + final String eveningLabel; + + /// Padding around the time picker. + final EdgeInsets padding; + + final _selectedTimeOfDay = _SelectedTimeOfDay(); + + @override + Widget build( + BuildContext context, + String? buildInitialValue, + Function({bool needsBlur}) onBlurBackground, + ) { + void updateSelectedTimeOfDay(_TimeOfDay timeOfDay) { + if (_selectedTimeOfDay.selectedTimeOfDay == timeOfDay) return; + _selectedTimeOfDay.selectedTimeOfDay = timeOfDay; + currentValue = null; + } + + void updateSelectedTimeAsString(String? time) { + currentValue = time; + onValueChanged?.call(time ?? ""); + _selectedTimeOfDay.selectedTime = time; + } + + void updateSelectedTime(double time) { + if (currentValue == time.toString()) { + updateSelectedTimeAsString(null); + } else { + updateSelectedTimeAsString(time.toString()); + } + } + + if (currentValue != null) { + var currentValueAsDouble = double.parse(currentValue!); + for (var timeOfDay in _TimeOfDay.values) { + if (_isTimeWithinTimeOfDay( + currentValueAsDouble, + currentValueAsDouble, + timeOfDay, + )) { + _selectedTimeOfDay.selectedTimeOfDay = timeOfDay; + } + } + updateSelectedTimeAsString(currentValue); + } else { + for (var timeOfDay in _TimeOfDay.values) { + if (_isTimeWithinTimeOfDay(beginTime, endTime, timeOfDay)) { + _selectedTimeOfDay.selectedTimeOfDay = timeOfDay; + break; + } + } + } + + return buildOutline( + context, + ListenableBuilder( + listenable: _selectedTimeOfDay, + builder: (context, _) { + var startTime = _selectedTimeOfDay.selection != null + ? _selectedTimeOfDay.selection!.minTime.clamp(beginTime, endTime) + : beginTime; + var finalTime = _selectedTimeOfDay.selection != null + ? _selectedTimeOfDay.selection!.maxTime.clamp(beginTime, endTime) + : endTime; + + return Column( + children: [ + _TimeOfDaySelector( + selectedTimeOfDay: _selectedTimeOfDay, + updateSelectedTimeOfDay: updateSelectedTimeOfDay, + startTime: beginTime, + endTime: endTime, + morningLabel: morningLabel, + afternoonLabel: afternoonLabel, + eveningLabel: eveningLabel, + padding: padding, + ), + _TimeWrap( + currentValue: currentValue ?? "", + startTime: startTime, + finalTime: finalTime, + interval: interval, + onTap: updateSelectedTime, + validate: super.validate, + ), + ], + ); + }, + ), + onBlurBackground, + ); + } +} + +bool _isTimeWithinTimeOfDay( + double openingTime, + double closingTime, + _TimeOfDay timeOfDay, +) => + (timeOfDay.minTime >= openingTime && timeOfDay.minTime <= closingTime) || + (timeOfDay.maxTime > openingTime && timeOfDay.maxTime <= closingTime) || + (timeOfDay.minTime <= openingTime && timeOfDay.maxTime >= closingTime); + +class _TimeOfDaySelector extends StatelessWidget { + const _TimeOfDaySelector({ + required this.selectedTimeOfDay, + required this.updateSelectedTimeOfDay, + required this.startTime, + required this.endTime, + required this.morningLabel, + required this.afternoonLabel, + required this.eveningLabel, + required this.padding, + }); + + final _SelectedTimeOfDay selectedTimeOfDay; + final Function(_TimeOfDay) updateSelectedTimeOfDay; + final double startTime; + final double endTime; + final String morningLabel; + final String afternoonLabel; + final String eveningLabel; + final EdgeInsets padding; + + @override + Widget build(BuildContext context) { + var theme = Theme.of(context); + + String getLabelName(_TimeOfDay timeOfDay) => switch (timeOfDay) { + _TimeOfDay.morning => morningLabel, + _TimeOfDay.afternoon => afternoonLabel, + _TimeOfDay.evening => eveningLabel, + }; + + return Padding( + padding: padding, + child: DecoratedBox( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(90), + border: Border.all( + color: theme.colorScheme.primary, + strokeAlign: BorderSide.strokeAlignOutside, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + for (var timeOfDay in _TimeOfDay.values) ...[ + if (_isTimeWithinTimeOfDay(startTime, endTime, timeOfDay)) ...[ + GestureDetector( + onTap: () => updateSelectedTimeOfDay(timeOfDay), + child: DecoratedBox( + decoration: BoxDecoration( + color: selectedTimeOfDay.selectedTimeOfDay == timeOfDay + ? theme.colorScheme.primary + : Colors.white, + borderRadius: BorderRadius.circular(90), + ), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 8, + ), + child: Text( + getLabelName(timeOfDay), + style: theme.textTheme.labelMedium?.copyWith( + color: + selectedTimeOfDay.selectedTimeOfDay == timeOfDay + ? Colors.white + : theme.colorScheme.primary, + ), + ), + ), + ), + ), + ], + ], + ], + ), + ), + ); + } +} + +class _TimeWrap extends FormField { + _TimeWrap({ + required this.currentValue, + required this.startTime, + required this.finalTime, + required this.interval, + required this.onTap, + required String? Function(T?) validate, + }) : super( + validator: (value) => validate(currentValue), + builder: (FormFieldState field) => Column( + children: [ + Wrap( + children: [ + for (var i = startTime; i < finalTime; i += interval) ...[ + _TimeWrapContent( + i: i, + currentValue: currentValue, + onTap: onTap, + ), + ], + ], + ), + if (field.hasError) ...[ + FormFieldErrorBuilder(errorMessage: field.errorText!), + ], + ], + ), + ); + + final T currentValue; + final double startTime; + final double finalTime; + final double interval; + final Function(double) onTap; +} + +class _TimeWrapContent extends StatelessWidget { + const _TimeWrapContent({ + required this.i, + required this.currentValue, + required this.onTap, + }); + + final double i; + final T currentValue; + final Function(double) onTap; + + @override + Widget build(BuildContext context) { + var theme = Theme.of(context); + + var boxDecoration = BoxDecoration( + color: currentValue == i.toString() + ? theme.colorScheme.primary + : Colors.white, + borderRadius: BorderRadius.circular(16), + ); + + var decoratedBox = Container( + decoration: boxDecoration, + width: MediaQuery.of(context).size.width * .25, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 28, + vertical: 12, + ), + child: Text( + '${i.floor().toString().padLeft(2, '0')}:' + '${((i - i.floor()) * 60).toInt().toString().padLeft(2, '0')}', + style: theme.textTheme.labelMedium?.copyWith( + color: currentValue == i.toString() + ? Colors.white + : theme.colorScheme.primary, + ), + ), + ), + ); + + return GestureDetector( + onTap: () => onTap(i), + child: Padding( + padding: const EdgeInsets.all(4.0), + child: decoratedBox, + ), + ); + } +} + +class _SelectedTimeOfDay extends ChangeNotifier { + _TimeOfDay? selection; + String? time = ""; + + _TimeOfDay? get selectedTimeOfDay => selection; + String? get selectedTime => time; + + set selectedTimeOfDay(_TimeOfDay? value) { + selection = value; + notifyListeners(); + } + + set selectedTime(String? value) { + time = value; + notifyListeners(); + } +} + +enum _TimeOfDay { + morning(0, 12), + afternoon(12, 18), + evening(18, 24); + + const _TimeOfDay(this.minTime, this.maxTime); + + final double minTime; + final double maxTime; +} diff --git a/packages/flutter_order_details/lib/src/widgets/order_detail_screen.dart b/packages/flutter_order_details/lib/src/widgets/order_detail_screen.dart new file mode 100644 index 0000000..0304e0b --- /dev/null +++ b/packages/flutter_order_details/lib/src/widgets/order_detail_screen.dart @@ -0,0 +1,273 @@ +import "package:flutter/material.dart"; +import "package:flutter_order_details/flutter_order_details.dart"; + +/// Order Detail Screen. +class OrderDetailScreen extends StatefulWidget { + /// Screen that builds all forms based on the configuration. + const OrderDetailScreen({ + required this.configuration, + super.key, + }); + + /// Configuration for the screen. + final OrderDetailConfiguration configuration; + + @override + State createState() => _OrderDetailScreenState(); +} + +class _OrderDetailScreenState extends State { + final _CurrentStep _currentStep = _CurrentStep(); + + final OrderResult _orderResult = OrderResult(order: {}); + + bool _blurBackground = false; + + void _toggleBlurBackground({bool? needsBlur}) { + setState(() { + _blurBackground = needsBlur!; + }); + } + + @override + Widget build(BuildContext context) { + var theme = Theme.of(context); + + var pageBody = SafeArea( + left: false, + right: false, + bottom: true, + child: _OrderDetailBody( + configuration: widget.configuration, + orderResult: _orderResult, + currentStep: _currentStep, + onBlurBackground: _toggleBlurBackground, + ), + ); + + var pageBlur = GestureDetector( + onTap: () => _toggleBlurBackground(needsBlur: false), + child: Container( + width: double.infinity, + height: double.infinity, + decoration: BoxDecoration( + color: theme.colorScheme.surface.withOpacity(0.5), + ), + ), + ); + + return Scaffold( + appBar: widget.configuration.appBar, + body: Stack( + children: [ + pageBody, + if (_blurBackground) pageBlur, + ], + ), + ); + } +} + +class _CurrentStep extends ChangeNotifier { + int _step = 0; + + int get step => _step; + + void increment() { + _step++; + notifyListeners(); + } + + void decrement() { + _step--; + notifyListeners(); + } +} + +class _OrderDetailBody extends StatelessWidget { + const _OrderDetailBody({ + required this.configuration, + required this.orderResult, + required this.currentStep, + required this.onBlurBackground, + }); + + final OrderDetailConfiguration configuration; + final OrderResult orderResult; + final _CurrentStep currentStep; + final Function({bool needsBlur}) onBlurBackground; + + @override + Widget build(BuildContext context) => ListenableBuilder( + listenable: currentStep, + builder: (context, _) => Builder( + builder: (context) => _FormBuilder( + currentStep: currentStep, + orderResult: orderResult, + configuration: configuration, + onBlurBackground: onBlurBackground, + ), + ), + ); +} + +class _FormBuilder extends StatelessWidget { + const _FormBuilder({ + required this.currentStep, + required this.configuration, + required this.orderResult, + required this.onBlurBackground, + }); + + final _CurrentStep currentStep; + final OrderDetailConfiguration configuration; + final OrderResult orderResult; + + final Function({bool needsBlur}) onBlurBackground; + + @override + Widget build(BuildContext context) { + var theme = Theme.of(context); + + var progressIndicator = LinearProgressIndicator( + value: currentStep.step / configuration.steps.length, + backgroundColor: theme.colorScheme.surface, + ); + + var stepForm = Form( + key: configuration.steps[currentStep.step].formKey, + child: _StepBuilder( + configuration: configuration, + currentStep: configuration.steps[currentStep.step], + orderResult: orderResult, + theme: theme, + onBlurBackground: onBlurBackground, + ), + ); + + void onPressedNext() { + var formInfo = configuration.steps[currentStep.step]; + var formkey = formInfo.formKey; + for (var input in formInfo.fields) { + orderResult.order[input.outputKey] = input.currentValue; + } + + if (formkey.currentState!.validate()) { + currentStep.increment(); + } + } + + void onPressedPrevious() { + var formInfo = configuration.steps[currentStep.step]; + for (var input in formInfo.fields) { + orderResult.order[input.outputKey] = input.currentValue; + } + + currentStep.decrement(); + } + + void onPressedComplete() { + var formInfo = configuration.steps[currentStep.step]; + var formkey = formInfo.formKey; + for (var input in formInfo.fields) { + orderResult.order[input.outputKey] = input.currentValue; + } + + if (formkey.currentState!.validate()) { + configuration.onCompleted(orderResult); + } + } + + var navigationControl = Row( + children: [ + if (currentStep.step > 0) ...[ + TextButton( + onPressed: onPressedPrevious, + child: Text( + configuration.localization.backButton, + ), + ), + ], + const Spacer(), + if (currentStep.step < configuration.steps.length - 1) ...[ + TextButton( + onPressed: onPressedNext, + child: Text( + configuration.localization.nextButton, + ), + ), + ] else ...[ + TextButton( + onPressed: onPressedComplete, + child: Text( + configuration.localization.completeButton, + ), + ), + ], + ], + ); + + return Stack( + children: [ + SingleChildScrollView( + child: Column( + children: [ + if (configuration.progressIndicator) ...[ + progressIndicator, + ], + stepForm, + ], + ), + ), + Align( + alignment: Alignment.bottomCenter, + child: navigationControl, + ), + ], + ); + } +} + +class _StepBuilder extends StatelessWidget { + const _StepBuilder({ + required this.configuration, + required this.currentStep, + required this.orderResult, + required this.theme, + required this.onBlurBackground, + }); + + final OrderDetailConfiguration configuration; + final OrderDetailStep currentStep; + final OrderResult orderResult; + final ThemeData theme; + final Function({bool needsBlur}) onBlurBackground; + + @override + Widget build(BuildContext context) { + var title = currentStep.stepName != null + ? Padding( + padding: configuration.titlePadding, + child: Text( + currentStep.stepName!, + style: theme.textTheme.titleMedium, + ), + ) + : const SizedBox.shrink(); + + return Column( + children: [ + title, + for (var input in currentStep.fields) + Padding( + padding: configuration.inputFieldPadding, + child: input.build( + context, + orderResult.order[input.outputKey], + onBlurBackground, + ), + ), + ], + ); + } +} diff --git a/packages/flutter_order_details/pubspec.yaml b/packages/flutter_order_details/pubspec.yaml new file mode 100644 index 0000000..7aaf793 --- /dev/null +++ b/packages/flutter_order_details/pubspec.yaml @@ -0,0 +1,31 @@ +name: flutter_order_details +description: "A Flutter module for order details." +version: 1.0.0+1 +homepage: + +environment: + sdk: '>=3.3.0 <4.0.0' + +dependencies: + flutter: + sdk: flutter + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_iconica_analysis: + git: + url: https://github.com/Iconica-Development/flutter_iconica_analysis + ref: 7.0.0 + +flutter: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic