From a7c3b3b192860db01a09e03ffcd3f54acebe529a Mon Sep 17 00:00:00 2001 From: Niels Gorter Date: Tue, 29 Nov 2022 13:16:44 +0100 Subject: [PATCH] initial commit --- .gitignore | 37 ++ .metadata | 10 + CHANGELOG.md | 3 + CONTRIBUTING.md | 198 ++++++++++ LICENSE.md | 29 ++ README.md | 23 ++ analysis_options.yaml | 4 + example/.gitignore | 44 +++ example/.metadata | 45 +++ example/analysis_options.yaml | 29 ++ example/lib/main.dart | 128 ++++++ example/pubspec.lock | 196 ++++++++++ example/pubspec.yaml | 96 +++++ lib/flutter_input_library.dart | 7 + lib/src/inputs/carousel/carousel.dart | 41 ++ .../inputs/carousel/carousel_controller.dart | 150 +++++++ lib/src/inputs/carousel/carousel_form.dart | 43 ++ lib/src/inputs/carousel/carousel_options.dart | 220 +++++++++++ lib/src/inputs/carousel/carousel_slider.dart | 358 +++++++++++++++++ lib/src/inputs/carousel/carousel_state.dart | 45 +++ lib/src/inputs/carousel/carousel_utils.dart | 27 ++ lib/src/inputs/date_picker/date_picker.dart | 68 ++++ .../inputs/date_picker/date_picker_field.dart | 148 +++++++ lib/src/inputs/inputs.dart | 7 + .../number_picker/decimal_numberpicker.dart | 116 ++++++ .../number_picker/infinite_listview.dart | 366 ++++++++++++++++++ .../inputs/number_picker/number_picker.dart | 74 ++++ .../number_picker/number_picker_field.dart | 309 +++++++++++++++ lib/src/inputs/slider/slider.dart | 40 ++ lib/src/inputs/slider/slider_field.dart | 30 ++ lib/src/inputs/switch/switch.dart | 38 ++ lib/src/inputs/switch/switch_field.dart | 63 +++ lib/src/inputs/text/password.dart | 58 +++ lib/src/inputs/text/plain_text.dart | 132 +++++++ lib/src/utils/utils.dart | 5 + lib/src/utils/validators/email/email.dart | 15 + lib/src/utils/validators/validators.dart | 5 + pubspec.yaml | 56 +++ 38 files changed, 3263 insertions(+) create mode 100644 .gitignore create mode 100644 .metadata create mode 100644 CHANGELOG.md create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE.md create mode 100644 README.md create mode 100644 analysis_options.yaml create mode 100644 example/.gitignore create mode 100644 example/.metadata create mode 100644 example/analysis_options.yaml create mode 100644 example/lib/main.dart create mode 100644 example/pubspec.lock create mode 100644 example/pubspec.yaml create mode 100644 lib/flutter_input_library.dart create mode 100644 lib/src/inputs/carousel/carousel.dart create mode 100644 lib/src/inputs/carousel/carousel_controller.dart create mode 100644 lib/src/inputs/carousel/carousel_form.dart create mode 100644 lib/src/inputs/carousel/carousel_options.dart create mode 100644 lib/src/inputs/carousel/carousel_slider.dart create mode 100644 lib/src/inputs/carousel/carousel_state.dart create mode 100644 lib/src/inputs/carousel/carousel_utils.dart create mode 100644 lib/src/inputs/date_picker/date_picker.dart create mode 100644 lib/src/inputs/date_picker/date_picker_field.dart create mode 100644 lib/src/inputs/inputs.dart create mode 100644 lib/src/inputs/number_picker/decimal_numberpicker.dart create mode 100644 lib/src/inputs/number_picker/infinite_listview.dart create mode 100644 lib/src/inputs/number_picker/number_picker.dart create mode 100644 lib/src/inputs/number_picker/number_picker_field.dart create mode 100644 lib/src/inputs/slider/slider.dart create mode 100644 lib/src/inputs/slider/slider_field.dart create mode 100644 lib/src/inputs/switch/switch.dart create mode 100644 lib/src/inputs/switch/switch_field.dart create mode 100644 lib/src/inputs/text/password.dart create mode 100644 lib/src/inputs/text/plain_text.dart create mode 100644 lib/src/utils/utils.dart create mode 100644 lib/src/utils/validators/email/email.dart create mode 100644 lib/src/utils/validators/validators.dart create mode 100644 pubspec.yaml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..54c2830 --- /dev/null +++ b/.gitignore @@ -0,0 +1,37 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock. +/pubspec.lock +**/doc/api/ +.dart_tool/ +.packages +build/ + +example/linux +example/macos +example/windows +example/web +example/android +example/ios diff --git a/.metadata b/.metadata new file mode 100644 index 0000000..1e05dac --- /dev/null +++ b/.metadata @@ -0,0 +1,10 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: eb6d86ee27deecba4a83536aa20f366a6044895c + channel: stable + +project_type: package diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..3787a91 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,3 @@ +## 0.0.1 + +* Initial release, retrieved inputs from flutter_form diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..e6350b8 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,198 @@ +# 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 + +- [Code of conduct](#code-of-conduct) +- [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 occurance +- 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/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..d419e05 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,29 @@ +BSD 3-Clause License + +Copyright (c) 2022, Iconica Development +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. 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. + +3. 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/README.md b/README.md new file mode 100644 index 0000000..e13c973 --- /dev/null +++ b/README.md @@ -0,0 +1,23 @@ +[![style: effective dart](https://img.shields.io/badge/style-effective_dart-40c4ff.svg)](https://github.com/tenhobi/effective_dart) + +Generic input library for Flutter. + +## Setup + +Add `flutter_input_library` to your `pubspec.yaml`: + +## How to use + +Look at the example for a simple [example](./example/lib/main.dart) + +## Issues + +Please file any issues, bugs or feature request as an issue on our [GitHub](https://github.com/Iconica-Development/flutter_input_library/issues) 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 plugin (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_input_library/pulls). + +## Author + +This `flutter_input_library` for Flutter is developed by [Iconica](https://iconica.nl). You can contact us at diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..a5744c1 --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1,4 @@ +include: package:flutter_lints/flutter.yaml + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/example/.gitignore b/example/.gitignore new file mode 100644 index 0000000..24476c5 --- /dev/null +++ b/example/.gitignore @@ -0,0 +1,44 @@ +# 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 +.packages +.pub-cache/ +.pub/ +/build/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release diff --git a/example/.metadata b/example/.metadata new file mode 100644 index 0000000..3c0cd78 --- /dev/null +++ b/example/.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. + +version: + revision: eb6d86ee27deecba4a83536aa20f366a6044895c + channel: stable + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: eb6d86ee27deecba4a83536aa20f366a6044895c + base_revision: eb6d86ee27deecba4a83536aa20f366a6044895c + - platform: android + create_revision: eb6d86ee27deecba4a83536aa20f366a6044895c + base_revision: eb6d86ee27deecba4a83536aa20f366a6044895c + - platform: ios + create_revision: eb6d86ee27deecba4a83536aa20f366a6044895c + base_revision: eb6d86ee27deecba4a83536aa20f366a6044895c + - platform: linux + create_revision: eb6d86ee27deecba4a83536aa20f366a6044895c + base_revision: eb6d86ee27deecba4a83536aa20f366a6044895c + - platform: macos + create_revision: eb6d86ee27deecba4a83536aa20f366a6044895c + base_revision: eb6d86ee27deecba4a83536aa20f366a6044895c + - platform: web + create_revision: eb6d86ee27deecba4a83536aa20f366a6044895c + base_revision: eb6d86ee27deecba4a83536aa20f366a6044895c + - platform: windows + create_revision: eb6d86ee27deecba4a83536aa20f366a6044895c + base_revision: eb6d86ee27deecba4a83536aa20f366a6044895c + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/example/analysis_options.yaml b/example/analysis_options.yaml new file mode 100644 index 0000000..61b6c4d --- /dev/null +++ b/example/analysis_options.yaml @@ -0,0 +1,29 @@ +# 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-lang.github.io/linter/lints/index.html. + # + # 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/example/lib/main.dart b/example/lib/main.dart new file mode 100644 index 0000000..e123051 --- /dev/null +++ b/example/lib/main.dart @@ -0,0 +1,128 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_input_library/flutter_input_library.dart'; +import 'package:intl/intl.dart'; + +void main() { + runApp(const MyApp()); +} + +class MyApp extends StatelessWidget { + const MyApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'Flutter Demo', + theme: ThemeData( + primarySwatch: Colors.blue, + ), + home: const MyHomePage(title: 'Flutter Demo Home Page'), + ); + } +} + +class MyHomePage extends StatefulWidget { + const MyHomePage({super.key, required this.title}); + + final String title; + + @override + State createState() => _MyHomePageState(); +} + +class _MyHomePageState extends State { + var formKey = GlobalKey(); + + @override + void initState() { + super.initState(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(widget.title), + ), + body: Center( + child: Form( + key: formKey, + child: SingleChildScrollView( + child: Column( + children: [ + FlutterFormInputSwitch( + initialValue: true, + onChanged: (v) { + print('Switch changed to $v'); + }, + ), + FlutterFormInputDateTime( + inputType: FlutterFormDateTimeType.dateTime, + dateFormat: DateFormat('dd/MM/yyyy HH:mm'), + onChanged: (v) { + print('Date changed to $v'); + }, + ), + FlutterFormInputNumberPicker( + onChanged: (v) { + print('Number changed to $v'); + }, + ), + FlutterFormInputSlider( + onChanged: (v) { + print('Slider changed to $v'); + }, + ), + SizedBox( + height: 100, + width: 100, + child: FlutterFormInputCarousel( + onChanged: (v) { + print('Carousel changed to $v'); + }, + items: [ + Container( + height: 100, + width: 100, + color: Colors.red, + ), + Container( + height: 100, + width: 100, + color: Colors.green, + ), + Container( + height: 100, + width: 100, + color: Colors.blue, + ) + ], + ), + ), + FlutterFormInputPlainText( + onChanged: (v) { + print('Plain text changed to $v'); + }, + ), + SizedBox( + height: 50, + width: 200, + child: FlutterFormInputMultiLine( + onChanged: (v) { + print('Multi line changed to $v'); + }, + ), + ), + FlutterFormInputPassword( + onChanged: (v) { + print('Password changed to $v'); + }, + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/example/pubspec.lock b/example/pubspec.lock new file mode 100644 index 0000000..f5117ad --- /dev/null +++ b/example/pubspec.lock @@ -0,0 +1,196 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + async: + dependency: transitive + description: + name: async + url: "https://pub.dartlang.org" + source: hosted + version: "2.9.0" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.0" + characters: + dependency: transitive + description: + name: characters + url: "https://pub.dartlang.org" + source: hosted + version: "1.2.1" + clock: + dependency: transitive + description: + name: clock + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.1" + collection: + dependency: transitive + description: + name: collection + url: "https://pub.dartlang.org" + source: hosted + version: "1.16.0" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.5" + fake_async: + dependency: transitive + description: + name: fake_async + url: "https://pub.dartlang.org" + source: hosted + version: "1.3.1" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_input_library: + dependency: "direct main" + description: + path: ".." + relative: true + source: path + version: "0.0.1" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.1" + flutter_riverpod: + dependency: transitive + description: + name: flutter_riverpod + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.1" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + intl: + dependency: "direct main" + description: + name: intl + url: "https://pub.dartlang.org" + source: hosted + version: "0.17.0" + lints: + dependency: transitive + description: + name: lints + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.1" + matcher: + dependency: transitive + description: + name: matcher + url: "https://pub.dartlang.org" + source: hosted + version: "0.12.12" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + url: "https://pub.dartlang.org" + source: hosted + version: "0.1.5" + meta: + dependency: transitive + description: + name: meta + url: "https://pub.dartlang.org" + source: hosted + version: "1.8.0" + path: + dependency: transitive + description: + name: path + url: "https://pub.dartlang.org" + source: hosted + version: "1.8.2" + riverpod: + dependency: transitive + description: + name: riverpod + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.1" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.99" + source_span: + dependency: transitive + description: + name: source_span + url: "https://pub.dartlang.org" + source: hosted + version: "1.9.0" + stack_trace: + dependency: transitive + description: + name: stack_trace + url: "https://pub.dartlang.org" + source: hosted + version: "1.10.0" + state_notifier: + dependency: transitive + description: + name: state_notifier + url: "https://pub.dartlang.org" + source: hosted + version: "0.7.2+1" + stream_channel: + dependency: transitive + description: + name: stream_channel + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.0" + string_scanner: + dependency: transitive + description: + name: string_scanner + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + url: "https://pub.dartlang.org" + source: hosted + version: "1.2.1" + test_api: + dependency: transitive + description: + name: test_api + url: "https://pub.dartlang.org" + source: hosted + version: "0.4.12" + vector_math: + dependency: transitive + description: + name: vector_math + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.2" +sdks: + dart: ">=2.18.2 <3.0.0" + flutter: ">=3.0.0" diff --git a/example/pubspec.yaml b/example/pubspec.yaml new file mode 100644 index 0000000..da72603 --- /dev/null +++ b/example/pubspec.yaml @@ -0,0 +1,96 @@ +name: example +description: A new Flutter project. + +# The following line prevents the package from being accidentally published to +# pub.dev using `flutter pub publish`. This is preferred for private packages. +publish_to: 'none' # Remove this line if you wish to publish to pub.dev + +# The following defines the version and build number for your application. +# A version number is three numbers separated by dots, like 1.2.43 +# followed by an optional build number separated by a +. +# Both the version and the builder number may be overridden in flutter +# build by specifying --build-name and --build-number, respectively. +# In Android, build-name is used as versionName while build-number used as versionCode. +# Read more about Android versioning at https://developer.android.com/studio/publish/versioning +# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion. +# Read more about iOS versioning at +# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html +# In Windows, build-name is used as the major, minor, and patch parts +# of the product and file versions while build-number is used as the build suffix. +version: 1.0.0+1 + +environment: + sdk: '>=2.18.2 <3.0.0' + +# Dependencies specify other packages that your package needs in order to work. +# To automatically upgrade your package dependencies to the latest versions +# consider running `flutter pub upgrade --major-versions`. Alternatively, +# dependencies can be manually updated by changing the version numbers below to +# the latest version available on pub.dev. To see which dependencies have newer +# versions available, run `flutter pub outdated`. +dependencies: + flutter: + sdk: flutter + + intl: any + + flutter_input_library: + path: ../ + + + # The following adds the Cupertino Icons font to your application. + # Use with the CupertinoIcons class for iOS style icons. + cupertino_icons: ^1.0.2 + +dev_dependencies: + flutter_test: + sdk: flutter + + # The "flutter_lints" package below contains a set of recommended lints to + # encourage good coding practices. The lint set provided by the package is + # activated in the `analysis_options.yaml` file located at the root of your + # package. See that file for information about deactivating specific lint + # rules and activating additional ones. + flutter_lints: ^2.0.0 + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter packages. +flutter: + + # The following line ensures that the Material Icons font is + # included with your application, so that you can use the icons in + # the material Icons class. + uses-material-design: true + + # To add assets to your application, add an assets section, like this: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/assets-and-images/#resolution-aware + + # For details regarding adding assets from package dependencies, see + # https://flutter.dev/assets-and-images/#from-packages + + # To add custom fonts to your application, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts from package dependencies, + # see https://flutter.dev/custom-fonts/#from-packages diff --git a/lib/flutter_input_library.dart b/lib/flutter_input_library.dart new file mode 100644 index 0000000..37126cb --- /dev/null +++ b/lib/flutter_input_library.dart @@ -0,0 +1,7 @@ +// SPDX-FileCopyrightText: 2022 Iconica +// +// SPDX-License-Identifier: BSD-3-Clause +library flutter_input_library; + +export 'src/inputs/inputs.dart'; +export 'src/utils/utils.dart'; diff --git a/lib/src/inputs/carousel/carousel.dart b/lib/src/inputs/carousel/carousel.dart new file mode 100644 index 0000000..c8595db --- /dev/null +++ b/lib/src/inputs/carousel/carousel.dart @@ -0,0 +1,41 @@ +// SPDX-FileCopyrightText: 2022 Iconica +// +// SPDX-License-Identifier: BSD-3-Clause + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'carousel_form.dart'; + +class FlutterFormInputCarousel extends ConsumerWidget { + const FlutterFormInputCarousel({ + Key? key, + required this.items, + this.height = 425, + this.onSaved, + this.onChanged, + this.initialValue, + this.validator, + }) : super( + key: key, + ); + + final List items; + final double height; + final Function(int?)? onSaved; + final String Function(int?)? validator; + final Function(int?)? onChanged; + final int? initialValue; + + @override + Widget build(BuildContext context, WidgetRef ref) { + return CarouselFormField( + onSaved: (value) => onSaved?.call(value), + validator: (value) => validator?.call(value), + onChanged: (value) => onChanged?.call(value), + initialValue: initialValue ?? 0, + items: items, + height: height, + ); + } +} diff --git a/lib/src/inputs/carousel/carousel_controller.dart b/lib/src/inputs/carousel/carousel_controller.dart new file mode 100644 index 0000000..e0e4c9f --- /dev/null +++ b/lib/src/inputs/carousel/carousel_controller.dart @@ -0,0 +1,150 @@ +// SPDX-FileCopyrightText: 2022 Iconica +// +// SPDX-License-Identifier: BSD-3-Clause + +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import 'carousel_utils.dart'; +import 'carousel_options.dart'; +import 'carousel_state.dart'; + +abstract class CarouselController { + bool get ready; + + Future get onReady; + + Future nextPage({Duration? duration, Curve? curve}); + + Future previousPage({Duration? duration, Curve? curve}); + + void jumpToPage(int page); + + Future animateToPage(int page, {Duration? duration, Curve? curve}); + + void startAutoPlay(); + + void stopAutoPlay(); + + factory CarouselController() => CarouselControllerImpl(); +} + +class CarouselControllerImpl implements CarouselController { + final Completer _readyCompleter = Completer(); + + CarouselState? _state; + + set state(CarouselState? state) { + _state = state; + if (!_readyCompleter.isCompleted) { + _readyCompleter.complete(); + } + } + + void _setModeController() => + _state!.changeMode(CarouselPageChangedReason.controller); + + @override + bool get ready => _state != null; + + @override + Future get onReady => _readyCompleter.future; + + /// Animates the controlled [CarouselSlider] to the next page. + /// + /// The animation lasts for the given duration and follows the given curve. + /// The returned [Future] resolves when the animation completes. + @override + Future nextPage( + {Duration? duration = const Duration(milliseconds: 300), + Curve? curve = Curves.linear}) async { + final bool isNeedResetTimer = _state!.options.pauseAutoPlayOnManualNavigate; + if (isNeedResetTimer) { + _state!.onResetTimer(); + } + _setModeController(); + await _state!.pageController!.nextPage(duration: duration!, curve: curve!); + if (isNeedResetTimer) { + _state!.onResumeTimer(); + } + } + + /// Animates the controlled [CarouselSlider] to the previous page. + /// + /// The animation lasts for the given duration and follows the given curve. + /// The returned [Future] resolves when the animation completes. + @override + Future previousPage( + {Duration? duration = const Duration(milliseconds: 300), + Curve? curve = Curves.linear}) async { + final bool isNeedResetTimer = _state!.options.pauseAutoPlayOnManualNavigate; + if (isNeedResetTimer) { + _state!.onResetTimer(); + } + _setModeController(); + await _state!.pageController! + .previousPage(duration: duration!, curve: curve!); + if (isNeedResetTimer) { + _state!.onResumeTimer(); + } + } + + /// Changes which page is displayed in the controlled [CarouselSlider]. + /// + /// Jumps the page position from its current value to the given value, + /// without animation, and without checking if the new value is in range. + @override + void jumpToPage(int page) { + final index = getRealIndex(_state!.pageController!.page!.toInt(), + _state!.realPage - _state!.initialPage, _state!.itemCount); + + _setModeController(); + final int pageToJump = _state!.pageController!.page!.toInt() + page - index; + return _state!.pageController!.jumpToPage(pageToJump); + } + + /// Animates the controlled [CarouselSlider] from the current page to the + /// given page. + /// + /// The animation lasts for the given duration and follows the given curve. + /// The returned [Future] resolves when the animation completes. + @override + Future animateToPage(int page, + {Duration? duration = const Duration(milliseconds: 300), + Curve? curve = Curves.linear}) async { + final bool isNeedResetTimer = _state!.options.pauseAutoPlayOnManualNavigate; + if (isNeedResetTimer) { + _state!.onResetTimer(); + } + final index = getRealIndex(_state!.pageController!.page!.toInt(), + _state!.realPage - _state!.initialPage, _state!.itemCount); + _setModeController(); + await _state!.pageController!.animateToPage( + _state!.pageController!.page!.toInt() + page - index, + duration: duration!, + curve: curve!); + if (isNeedResetTimer) { + _state!.onResumeTimer(); + } + } + + /// Starts the controlled [CarouselSlider] autoplay. + /// + /// The carousel will only autoPlay if the [autoPlay] parameter + /// in [CarouselOptions] is true. + @override + void startAutoPlay() { + _state!.onResumeTimer(); + } + + /// Stops the controlled [CarouselSlider] from autoplaying. + /// + /// This is a more on-demand way of doing this. Use the [autoPlay] + /// parameter in [CarouselOptions] to specify the autoPlay behaviour of the + /// carousel. + @override + void stopAutoPlay() { + _state!.onResetTimer(); + } +} diff --git a/lib/src/inputs/carousel/carousel_form.dart b/lib/src/inputs/carousel/carousel_form.dart new file mode 100644 index 0000000..6bd482d --- /dev/null +++ b/lib/src/inputs/carousel/carousel_form.dart @@ -0,0 +1,43 @@ +// SPDX-FileCopyrightText: 2022 Iconica +// +// SPDX-License-Identifier: BSD-3-Clause + +import 'package:flutter/material.dart'; +import 'carousel_slider.dart'; + +class CarouselFormField extends FormField { + CarouselFormField({ + Key? key, + required FormFieldSetter onSaved, + required FormFieldValidator validator, + void Function(int value)? onChanged, + void Function(int value)? onSubmit, + int initialValue = 0, + bool autovalidate = false, + required List items, + double height = 425, + }) : super( + key: key, + onSaved: onSaved, + validator: validator, + initialValue: initialValue, + builder: (FormFieldState state) { + return CarouselSlider( + options: CarouselOptions( + initialPage: initialValue, + onPageChanged: (index, reason) { + onChanged?.call(index); + + state.didChange(index); + }, + height: height, + aspectRatio: 2.0, + enlargeCenterPage: true, + enableInfiniteScroll: false, + ), + items: items.map((Widget item) { + return item; + }).toList(), + ); + }); +} diff --git a/lib/src/inputs/carousel/carousel_options.dart b/lib/src/inputs/carousel/carousel_options.dart new file mode 100644 index 0000000..f279d5f --- /dev/null +++ b/lib/src/inputs/carousel/carousel_options.dart @@ -0,0 +1,220 @@ +// SPDX-FileCopyrightText: 2022 Iconica +// +// SPDX-License-Identifier: BSD-3-Clause + +import 'package:flutter/material.dart'; + +enum CarouselPageChangedReason { timed, manual, controller } + +enum CenterPageEnlargeStrategy { scale, height } + +class CarouselOptions { + /// Set carousel height and overrides any existing [aspectRatio]. + final double? height; + + /// Aspect ratio is used if no height have been declared. + /// + /// Defaults to 16:9 aspect ratio. + final double aspectRatio; + + /// The fraction of the viewport that each page should occupy. + /// + /// Defaults to 0.8, which means each page fills 80% of the carousel. + final double viewportFraction; + + /// The initial page to show when first creating the [CarouselSlider]. + /// + /// Defaults to 0. + final int initialPage; + + ///Determines if carousel should loop infinitely or be limited to item length. + /// + ///Defaults to true, i.e. infinite loop. + final bool enableInfiniteScroll; + + /// Reverse the order of items if set to true. + /// + /// Defaults to false. + final bool reverse; + + /// Enables auto play, sliding one page at a time. + /// + /// Use [autoPlayInterval] to determine the frequency of slides. + /// Defaults to false. + final bool autoPlay; + + /// Sets Duration to determine the frequency of slides when [autoPlay] is set + /// to true. + /// Defaults to 4 seconds. + final Duration autoPlayInterval; + + /// The animation duration between two transitioning pages while in auto + /// playback. + /// + /// Defaults to 800 ms. + final Duration autoPlayAnimationDuration; + + /// Determines the animation curve physics. + /// + /// Defaults to [Curves.fastOutSlowIn]. + final Curve autoPlayCurve; + + /// Determines if current page should be larger than the side images, + /// creating a feeling of depth in the carousel. + /// + /// Defaults to false. + final bool? enlargeCenterPage; + + /// The axis along which the page view scrolls. + /// + /// Defaults to [Axis.horizontal]. + final Axis scrollDirection; + + /// Called whenever the page in the center of the viewport changes. + final Function(int index, CarouselPageChangedReason reason)? onPageChanged; + + /// Called whenever the carousel is scrolled + final ValueChanged? onScrolled; + + /// How the carousel should respond to user input. + /// + /// For example, determines how the items continues to animate after the + /// user stops dragging the page view. + /// + /// The physics are modified to snap to page boundaries using + /// [PageScrollPhysics] prior to being used. + /// + /// Defaults to matching platform conventions. + final ScrollPhysics? scrollPhysics; + + /// Set to false to disable page snapping, useful for custom scroll behavior. + /// + /// Default to `true`. + final bool pageSnapping; + + /// If `true`, the auto play function will be paused when user is interacting + /// with the carousel, and will be resumed when user finish interacting. + /// + /// Default to `true`. + final bool pauseAutoPlayOnTouch; + + /// If `true`, the auto play function will be paused when user is calling + /// [PageController]'s [nextPage] or [previousPage] or [animateToPage] method. + /// And after the animation complete, the auto play will be resumed. + /// + /// Default to `true`. + final bool pauseAutoPlayOnManualNavigate; + + /// If [enableInfiniteScroll] is `false`, and [autoPlay] is `true`, this option + /// decide the carousel should go to the first item when it reach the last item or not. + /// If set to `true`, the auto play will be paused when it reach the last item. + /// If set to `false`, the auto play function will animate to the first item + /// when it was in the last item. + final bool pauseAutoPlayInFiniteScroll; + + /// Pass a [PageStorageKey] if you want to keep the pageview's position when + /// it was recreated. + final PageStorageKey? pageViewKey; + + /// Use [enlargeStrategy] to determine which method to enlarge the center page. + final CenterPageEnlargeStrategy enlargeStrategy; + + /// Whether or not to disable the [Center] widget for each slide. + final bool disableCenter; + + /// Whether to add padding to both ends of the list. + /// If this is set to true and [viewportFraction] < 1.0, padding will be added + /// such that the first and last child slivers will be in the center of the 1 + /// viewport when scrolled all the way to the start or end, respectively. + /// + /// If [viewportFraction] >= 1.0, this property has no effect. + /// This property defaults to true and must not be null. + final bool padEnds; + + /// Exposed [clipBehavior] of [PageView] + final Clip clipBehavior; + + CarouselOptions({ + this.height, + this.aspectRatio = 16 / 9, + this.viewportFraction = 0.8, + this.initialPage = 0, + this.enableInfiniteScroll = true, + this.reverse = false, + this.autoPlay = false, + this.autoPlayInterval = const Duration(seconds: 4), + this.autoPlayAnimationDuration = const Duration(milliseconds: 800), + this.autoPlayCurve = Curves.fastOutSlowIn, + this.enlargeCenterPage = false, + this.onPageChanged, + this.onScrolled, + this.scrollPhysics, + this.pageSnapping = true, + this.scrollDirection = Axis.horizontal, + this.pauseAutoPlayOnTouch = true, + this.pauseAutoPlayOnManualNavigate = true, + this.pauseAutoPlayInFiniteScroll = false, + this.pageViewKey, + this.enlargeStrategy = CenterPageEnlargeStrategy.scale, + this.disableCenter = false, + this.padEnds = true, + this.clipBehavior = Clip.hardEdge, + }); + + ///Generate new [CarouselOptions] based on old ones. + + CarouselOptions copyWith( + {double? height, + double? aspectRatio, + double? viewportFraction, + int? initialPage, + bool? enableInfiniteScroll, + bool? reverse, + bool? autoPlay, + Duration? autoPlayInterval, + Duration? autoPlayAnimationDuration, + Curve? autoPlayCurve, + bool? enlargeCenterPage, + Function(int index, CarouselPageChangedReason reason)? onPageChanged, + ValueChanged? onScrolled, + ScrollPhysics? scrollPhysics, + bool? pageSnapping, + Axis? scrollDirection, + bool? pauseAutoPlayOnTouch, + bool? pauseAutoPlayOnManualNavigate, + bool? pauseAutoPlayInFiniteScroll, + PageStorageKey? pageViewKey, + CenterPageEnlargeStrategy? enlargeStrategy, + bool? disableCenter, + Clip? clipBehavior, + bool? padEnds}) => + CarouselOptions( + height: height ?? this.height, + aspectRatio: aspectRatio ?? this.aspectRatio, + viewportFraction: viewportFraction ?? this.viewportFraction, + initialPage: initialPage ?? this.initialPage, + enableInfiniteScroll: enableInfiniteScroll ?? this.enableInfiniteScroll, + reverse: reverse ?? this.reverse, + autoPlay: autoPlay ?? this.autoPlay, + autoPlayInterval: autoPlayInterval ?? this.autoPlayInterval, + autoPlayAnimationDuration: + autoPlayAnimationDuration ?? this.autoPlayAnimationDuration, + autoPlayCurve: autoPlayCurve ?? this.autoPlayCurve, + enlargeCenterPage: enlargeCenterPage ?? this.enlargeCenterPage, + onPageChanged: onPageChanged ?? this.onPageChanged, + onScrolled: onScrolled ?? this.onScrolled, + scrollPhysics: scrollPhysics ?? this.scrollPhysics, + pageSnapping: pageSnapping ?? this.pageSnapping, + scrollDirection: scrollDirection ?? this.scrollDirection, + pauseAutoPlayOnTouch: pauseAutoPlayOnTouch ?? this.pauseAutoPlayOnTouch, + pauseAutoPlayOnManualNavigate: + pauseAutoPlayOnManualNavigate ?? this.pauseAutoPlayOnManualNavigate, + pauseAutoPlayInFiniteScroll: + pauseAutoPlayInFiniteScroll ?? this.pauseAutoPlayInFiniteScroll, + pageViewKey: pageViewKey ?? this.pageViewKey, + enlargeStrategy: enlargeStrategy ?? this.enlargeStrategy, + disableCenter: disableCenter ?? this.disableCenter, + clipBehavior: clipBehavior ?? this.clipBehavior, + padEnds: padEnds ?? this.padEnds, + ); +} diff --git a/lib/src/inputs/carousel/carousel_slider.dart b/lib/src/inputs/carousel/carousel_slider.dart new file mode 100644 index 0000000..209a899 --- /dev/null +++ b/lib/src/inputs/carousel/carousel_slider.dart @@ -0,0 +1,358 @@ +// SPDX-FileCopyrightText: 2022 Iconica +// +// SPDX-License-Identifier: BSD-3-Clause + +library carousel_slider; + +import 'dart:async'; + +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'carousel_state.dart'; +import 'carousel_utils.dart'; + +import 'carousel_controller.dart'; +import 'carousel_options.dart'; + +export 'carousel_controller.dart'; +export 'carousel_options.dart'; + +typedef ExtendedIndexedWidgetBuilder = Widget Function( + BuildContext context, int index, int realIndex); + +class CarouselSlider extends StatefulWidget { + /// [CarouselOptions] to create a [CarouselState] with. + final CarouselOptions options; + + /// The widgets to be shown in the carousel of default constructor. + final List? items; + + /// The widget item builder that will be used to build item on demand + /// The third argument is the [PageView]'s real index, can be used to cooperate + /// with Hero. + final ExtendedIndexedWidgetBuilder? itemBuilder; + + /// A [MapController], used to control the map. + final CarouselControllerImpl _carouselController; + + final int? itemCount; + + CarouselSlider( + {required this.items, + required this.options, + CarouselController? carouselController, + Key? key}) + : itemBuilder = null, + itemCount = items != null ? items.length : 0, + _carouselController = carouselController != null + ? carouselController as CarouselControllerImpl + : CarouselController() as CarouselControllerImpl, + super(key: key); + + /// The on demand item builder constructor/ + CarouselSlider.builder( + {required this.itemCount, + required this.itemBuilder, + required this.options, + CarouselController? carouselController, + Key? key}) + : items = null, + _carouselController = carouselController != null + ? carouselController as CarouselControllerImpl + : CarouselController() as CarouselControllerImpl, + super(key: key); + + @override + CarouselSliderState createState() => CarouselSliderState(); +} + +class CarouselSliderState extends State + with TickerProviderStateMixin { + late CarouselControllerImpl carouselController; + Timer? timer; + + CarouselOptions get options => widget.options; + + CarouselState? carouselState; + + PageController? pageController; + + /// [mode] is related to why the page is being changed. + CarouselPageChangedReason mode = CarouselPageChangedReason.controller; + + CarouselSliderState(); + + void changeMode(CarouselPageChangedReason mode) { + this.mode = mode; + } + + @override + void didUpdateWidget(CarouselSlider oldWidget) { + carouselState!.options = options; + carouselState!.itemCount = widget.itemCount; + + /// [pageController] needs to be re-initialized to respond to state changes. + pageController = PageController( + viewportFraction: options.viewportFraction, + initialPage: carouselState!.realPage, + ); + carouselState!.pageController = pageController; + + /// handle autoplay when state changes + handleAutoPlay(); + + super.didUpdateWidget(oldWidget); + } + + @override + void initState() { + super.initState(); + carouselController = widget._carouselController; + + carouselState = CarouselState(options, clearTimer, resumeTimer, changeMode); + + carouselState!.itemCount = widget.itemCount; + carouselController.state = carouselState; + carouselState!.initialPage = widget.options.initialPage; + carouselState!.realPage = options.enableInfiniteScroll + ? carouselState!.realPage + carouselState!.initialPage + : carouselState!.initialPage; + handleAutoPlay(); + + pageController = PageController( + viewportFraction: options.viewportFraction, + initialPage: carouselState!.realPage, + ); + + carouselState!.pageController = pageController; + } + + Timer? getTimer() { + return widget.options.autoPlay + ? Timer.periodic(widget.options.autoPlayInterval, (_) { + final route = ModalRoute.of(context); + if (route?.isCurrent == false) { + return; + } + + CarouselPageChangedReason previousReason = mode; + changeMode(CarouselPageChangedReason.timed); + int nextPage = carouselState!.pageController!.page!.round() + 1; + int itemCount = widget.itemCount ?? widget.items!.length; + + if (nextPage >= itemCount && + widget.options.enableInfiniteScroll == false) { + if (widget.options.pauseAutoPlayInFiniteScroll) { + clearTimer(); + return; + } + nextPage = 0; + } + + carouselState!.pageController! + .animateToPage(nextPage, + duration: widget.options.autoPlayAnimationDuration, + curve: widget.options.autoPlayCurve) + .then((_) => changeMode(previousReason)); + }) + : null; + } + + void clearTimer() { + if (timer != null) { + timer?.cancel(); + timer = null; + } + } + + void resumeTimer() { + timer ??= getTimer(); + } + + void handleAutoPlay() { + bool autoPlayEnabled = widget.options.autoPlay; + + if (autoPlayEnabled && timer != null) return; + + clearTimer(); + if (autoPlayEnabled) { + resumeTimer(); + } + } + + Widget getGestureWrapper(Widget child) { + Widget wrapper; + if (widget.options.height != null) { + wrapper = SizedBox(height: widget.options.height, child: child); + } else { + wrapper = + AspectRatio(aspectRatio: widget.options.aspectRatio, child: child); + } + + return RawGestureDetector( + gestures: { + _MultipleGestureRecognizer: + GestureRecognizerFactoryWithHandlers<_MultipleGestureRecognizer>( + () => _MultipleGestureRecognizer(), + (_MultipleGestureRecognizer instance) { + instance.onStart = (_) { + onStart(); + }; + instance.onDown = (_) { + onPanDown(); + }; + instance.onEnd = (_) { + onPanUp(); + }; + instance.onCancel = () { + onPanUp(); + }; + }), + }, + child: NotificationListener( + onNotification: (Notification notification) { + if (widget.options.onScrolled != null && + notification is ScrollUpdateNotification) { + widget.options.onScrolled!(carouselState!.pageController!.page); + } + return false; + }, + child: wrapper, + ), + ); + } + + Widget getCenterWrapper(Widget child) { + if (widget.options.disableCenter) { + return Container( + child: child, + ); + } + return Center(child: child); + } + + Widget getEnlargeWrapper(Widget? child, + {double? width, double? height, double? scale}) { + if (widget.options.enlargeStrategy == CenterPageEnlargeStrategy.height) { + return SizedBox(width: width, height: height, child: child); + } + return Transform.scale( + scale: scale!, + child: SizedBox(width: width, height: height, child: child)); + } + + void onStart() { + changeMode(CarouselPageChangedReason.manual); + } + + void onPanDown() { + if (widget.options.pauseAutoPlayOnTouch) { + clearTimer(); + } + + changeMode(CarouselPageChangedReason.manual); + } + + void onPanUp() { + if (widget.options.pauseAutoPlayOnTouch) { + resumeTimer(); + } + } + + @override + void dispose() { + super.dispose(); + clearTimer(); + } + + @override + Widget build(BuildContext context) { + return getGestureWrapper(PageView.builder( + padEnds: widget.options.padEnds, + scrollBehavior: ScrollConfiguration.of(context).copyWith( + scrollbars: false, + overscroll: false, + dragDevices: {PointerDeviceKind.touch, PointerDeviceKind.mouse}, + ), + clipBehavior: widget.options.clipBehavior, + physics: widget.options.scrollPhysics, + scrollDirection: widget.options.scrollDirection, + pageSnapping: widget.options.pageSnapping, + controller: carouselState!.pageController, + reverse: widget.options.reverse, + itemCount: widget.options.enableInfiniteScroll ? null : widget.itemCount, + key: widget.options.pageViewKey, + onPageChanged: (int index) { + int currentPage = getRealIndex(index + carouselState!.initialPage, + carouselState!.realPage, widget.itemCount); + if (widget.options.onPageChanged != null) { + widget.options.onPageChanged!(currentPage, mode); + } + }, + itemBuilder: (BuildContext context, int idx) { + final int index = getRealIndex(idx + carouselState!.initialPage, + carouselState!.realPage, widget.itemCount); + + return AnimatedBuilder( + animation: carouselState!.pageController!, + child: (widget.items != null) + ? (widget.items!.isNotEmpty ? widget.items![index] : Container()) + : widget.itemBuilder!(context, index, idx), + builder: (BuildContext context, child) { + double distortionValue = 1.0; + // if [enlargeCenterPage] is true, we must calculate the carousel item's height + // to display the visual effect + + if (widget.options.enlargeCenterPage != null && + widget.options.enlargeCenterPage == true) { + // [pageController.page] can only be accessed after the first build, + // so in the first build we calculate the [itemOffset] manually + double itemOffset = 0; + var position = carouselState?.pageController?.position; + if (position != null && + position.hasPixels && + position.hasContentDimensions) { + var page = carouselState?.pageController?.page; + if (page != null) { + itemOffset = page - idx; + } + } else { + BuildContext storageContext = carouselState! + .pageController!.position.context.storageContext; + final double? previousSavedPosition = + PageStorage.of(storageContext)?.readState(storageContext) + as double?; + if (previousSavedPosition != null) { + itemOffset = previousSavedPosition - idx.toDouble(); + } else { + itemOffset = + carouselState!.realPage.toDouble() - idx.toDouble(); + } + } + + final num distortionRatio = + (1 - (itemOffset.abs() * 0.3)).clamp(0.0, 1.0); + distortionValue = + Curves.easeOut.transform(distortionRatio as double); + } + + final double height = widget.options.height ?? + MediaQuery.of(context).size.width * + (1 / widget.options.aspectRatio); + + if (widget.options.scrollDirection == Axis.horizontal) { + return getCenterWrapper(getEnlargeWrapper(child, + height: distortionValue * height, scale: distortionValue)); + } else { + return getCenterWrapper(getEnlargeWrapper(child, + width: distortionValue * MediaQuery.of(context).size.width, + scale: distortionValue)); + } + }, + ); + }, + )); + } +} + +class _MultipleGestureRecognizer extends PanGestureRecognizer {} diff --git a/lib/src/inputs/carousel/carousel_state.dart b/lib/src/inputs/carousel/carousel_state.dart new file mode 100644 index 0000000..6410931 --- /dev/null +++ b/lib/src/inputs/carousel/carousel_state.dart @@ -0,0 +1,45 @@ +// SPDX-FileCopyrightText: 2022 Iconica +// +// SPDX-License-Identifier: BSD-3-Clause + +import 'package:flutter/material.dart'; +import 'carousel_slider.dart'; + +class CarouselState { + /// The [CarouselOptions] to create this state + CarouselOptions options; + + /// [pageController] is created using the properties passed to the constructor + /// and can be used to control the [PageView] it is passed to. + PageController? pageController; + + /// The actual index of the [PageView]. + /// + /// This value can be ignored unless you know the carousel will be scrolled + /// backwards more then 10000 pages. + /// Defaults to 10000 to simulate infinite backwards scrolling. + int realPage = 10000; + + /// The initial index of the [PageView] on [CarouselSlider] init. + /// + int initialPage = 0; + + /// The widgets count that should be shown at carousel + int? itemCount; + + /// Will be called when using [pageController] to go to next page or + /// previous page. It will clear the autoPlay timer. + /// Internal use only + Function onResetTimer; + + /// Will be called when using pageController to go to next page or + /// previous page. It will restart the autoPlay timer. + /// Internal use only + Function onResumeTimer; + + /// The callback to set the Reason Carousel changed + Function(CarouselPageChangedReason) changeMode; + + CarouselState( + this.options, this.onResetTimer, this.onResumeTimer, this.changeMode); +} diff --git a/lib/src/inputs/carousel/carousel_utils.dart b/lib/src/inputs/carousel/carousel_utils.dart new file mode 100644 index 0000000..7d96007 --- /dev/null +++ b/lib/src/inputs/carousel/carousel_utils.dart @@ -0,0 +1,27 @@ +// SPDX-FileCopyrightText: 2022 Iconica +// +// SPDX-License-Identifier: BSD-3-Clause + +/// Converts an index of a set size to the corresponding index of a collection of another size +/// as if they were circular. +/// +/// Takes a [position] from collection Foo, a [base] from where Foo's index originated +/// and the [length] of a second collection Baa, for which the correlating index is sought. +/// +/// For example; We have a Carousel of 10000(simulating infinity) but only 6 images. +/// We need to repeat the images to give the illusion of a never ending stream. +/// By calling [getRealIndex] with position and base we get an offset. +/// This offset modulo our length, 6, will return a number between 0 and 5, which represent the image +/// to be placed in the given position. +int getRealIndex(int position, int base, int? length) { + final int offset = position - base; + return remainder(offset, length); +} + +/// Returns the remainder of the modulo operation [input] % [source], and adjust it for +/// negative values. +int remainder(int input, int? source) { + if (source == 0) return 0; + final int result = input % source!; + return result < 0 ? source + result : result; +} diff --git a/lib/src/inputs/date_picker/date_picker.dart b/lib/src/inputs/date_picker/date_picker.dart new file mode 100644 index 0000000..ad533ec --- /dev/null +++ b/lib/src/inputs/date_picker/date_picker.dart @@ -0,0 +1,68 @@ +// SPDX-FileCopyrightText: 2022 Iconica +// +// SPDX-License-Identifier: BSD-3-Clause + +import 'package:flutter/material.dart'; +import 'package:flutter_input_library/src/inputs/date_picker/date_picker_field.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:intl/intl.dart'; + +enum FlutterFormDateTimeType { + date, + time, + dateTime, + range, +} + +class FlutterFormInputDateTime extends ConsumerWidget { + const FlutterFormInputDateTime({ + Key? key, + this.label, + this.showIcon = true, + required this.inputType, + required this.dateFormat, + this.firstDate, + this.lastDate, + this.initialDate, + this.initialDateTimeRange, + this.icon = Icons.calendar_today, + this.initialValue, + this.onChanged, + this.onSaved, + this.validator, + }) : super( + key: key, + ); + + final Widget? label; + final bool showIcon; + final FlutterFormDateTimeType inputType; + final DateFormat dateFormat; + final DateTime? initialDate; + final DateTimeRange? initialDateTimeRange; + final DateTime? firstDate; + final DateTime? lastDate; + final IconData icon; + final String? initialValue; + final String? Function(String?)? validator; + final void Function(String?)? onSaved; + final void Function(String?)? onChanged; + + @override + Widget build(BuildContext context, WidgetRef ref) { + return DateTimeInputField( + label: label, + icon: icon, + firstDate: firstDate, + lastDate: lastDate, + inputType: inputType, + dateFormat: dateFormat, + initialDate: initialDate, + initialDateTimeRange: initialDateTimeRange, + initialValue: initialValue, + onChanged: (value) => onChanged?.call(value), + onSaved: (value) => onSaved?.call(value), + showIcon: showIcon, + ); + } +} diff --git a/lib/src/inputs/date_picker/date_picker_field.dart b/lib/src/inputs/date_picker/date_picker_field.dart new file mode 100644 index 0000000..2361c02 --- /dev/null +++ b/lib/src/inputs/date_picker/date_picker_field.dart @@ -0,0 +1,148 @@ +// SPDX-FileCopyrightText: 2022 Iconica +// +// SPDX-License-Identifier: BSD-3-Clause + +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_input_library/src/inputs/date_picker/date_picker.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:intl/intl.dart'; + +class DateTimeInputField extends ConsumerStatefulWidget { + const DateTimeInputField({ + Key? key, + required this.inputType, + this.label, + this.showIcon = true, + this.icon, + required this.dateFormat, + required this.firstDate, + required this.lastDate, + this.initialDate, + this.initialDateTimeRange, + this.initialValue, + this.onChanged, + this.onSaved, + this.validator, + }) : super( + key: key, + ); + final FlutterFormDateTimeType inputType; + final DateFormat dateFormat; + final bool showIcon; + final DateTime? firstDate; + final DateTime? lastDate; + final DateTime? initialDate; + final DateTimeRange? initialDateTimeRange; + final IconData? icon; + final Widget? label; + final String? initialValue; + final String? Function(String?)? validator; + final void Function(String?)? onSaved; + final void Function(String?)? onChanged; + + @override + ConsumerState createState() => _DateInputFieldState(); +} + +class _DateInputFieldState extends ConsumerState { + late final DateTime firstDate; + late final DateTime lastDate; + late final DateTime initialDate; + late final DateTimeRange initialDateRange; + String currentValue = ''; + + @override + void initState() { + firstDate = widget.firstDate ?? + DateTime.now().subtract( + const Duration(days: 1000), + ); + lastDate = widget.lastDate ?? + DateTime.now().add( + const Duration(days: 1000), + ); + initialDate = widget.initialDate ?? DateTime.now(); + initialDateRange = widget.initialDateTimeRange ?? + DateTimeRange( + start: DateTime.now(), + end: DateTime.now().add( + const Duration(days: 7), + ), + ); + + super.initState(); + } + + @override + Widget build(BuildContext context) { + Future getInputFromUser(FlutterFormDateTimeType inputType) async { + String userInput = ''; + switch (inputType) { + case FlutterFormDateTimeType.date: + DateTime? unformatted = await showDatePicker( + initialDate: initialDate, + context: context, + firstDate: firstDate, + lastDate: lastDate, + ); + userInput = unformatted != null + ? widget.dateFormat.format(unformatted) + : userInput; + break; + case FlutterFormDateTimeType.dateTime: + await getInputFromUser(FlutterFormDateTimeType.date) + .then((value) async { + if (value != '') { + String secondInput = + await getInputFromUser(FlutterFormDateTimeType.time); + if (secondInput != '') { + userInput = '$value $secondInput'; + } + } + }); + break; + case FlutterFormDateTimeType.range: + userInput = (await showDateRangePicker( + context: context, + firstDate: firstDate, + lastDate: lastDate, + initialDateRange: initialDateRange) + .then((value) { + return value != null + ? '${widget.dateFormat.format(value.start)} - ${widget.dateFormat.format(value.end)}' + : ''; + })) + .toString(); + break; + case FlutterFormDateTimeType.time: + userInput = await showTimePicker( + context: context, initialTime: TimeOfDay.now()) + .then((value) => value == null ? '' : value.format(context)); + } + return userInput; + } + + return TextFormField( + keyboardType: TextInputType.none, + readOnly: true, + key: Key(currentValue.toString()), + initialValue: widget.initialValue, + onSaved: (value) => widget.onSaved?.call(value), + onTap: () async { + String userInput = await getInputFromUser(widget.inputType); + setState(() { + currentValue = userInput != '' ? userInput : currentValue; + widget.onChanged?.call(userInput != '' ? userInput : currentValue); + }); + }, + validator: (value) => widget.validator?.call(value), + decoration: InputDecoration( + suffixIcon: widget.showIcon ? Icon(widget.icon) : null, + focusColor: Theme.of(context).primaryColor, + label: widget.label ?? const Text("Date"), + ), + ); + } +} diff --git a/lib/src/inputs/inputs.dart b/lib/src/inputs/inputs.dart new file mode 100644 index 0000000..a5d2623 --- /dev/null +++ b/lib/src/inputs/inputs.dart @@ -0,0 +1,7 @@ +export 'carousel/carousel.dart'; +export 'number_picker/number_picker.dart'; +export 'text/password.dart'; +export 'text/plain_text.dart'; +export 'slider/slider.dart'; +export 'switch/switch.dart'; +export 'date_picker/date_picker.dart'; diff --git a/lib/src/inputs/number_picker/decimal_numberpicker.dart b/lib/src/inputs/number_picker/decimal_numberpicker.dart new file mode 100644 index 0000000..57fb2de --- /dev/null +++ b/lib/src/inputs/number_picker/decimal_numberpicker.dart @@ -0,0 +1,116 @@ +// SPDX-FileCopyrightText: 2022 Iconica +// +// SPDX-License-Identifier: BSD-3-Clause + +import 'dart:math' as math; + +import 'package:flutter/material.dart'; + +import 'number_picker_field.dart'; + +class DecimalNumberPicker extends StatelessWidget { + final int minValue; + final int maxValue; + final double value; + final ValueChanged onChanged; + final int itemCount; + final double itemHeight; + final double itemWidth; + final Axis axis; + final TextStyle? textStyle; + final TextStyle? selectedTextStyle; + final bool haptics; + final TextMapper? integerTextMapper; + final TextMapper? decimalTextMapper; + final bool integerZeroPad; + + /// Decoration to apply to central box where the selected integer value is placed + final Decoration? integerDecoration; + + /// Decoration to apply to central box where the selected decimal value is placed + final Decoration? decimalDecoration; + + /// Inidcates how many decimal places to show + /// e.g. 0=>[1,2,3...], 1=>[1.0, 1.1, 1.2...] 2=>[1.00, 1.01, 1.02...] + final int decimalPlaces; + + const DecimalNumberPicker({ + Key? key, + required this.minValue, + required this.maxValue, + required this.value, + required this.onChanged, + this.itemCount = 3, + this.itemHeight = 50, + this.itemWidth = 100, + this.axis = Axis.vertical, + this.textStyle, + this.selectedTextStyle, + this.haptics = false, + this.decimalPlaces = 1, + this.integerTextMapper, + this.decimalTextMapper, + this.integerZeroPad = false, + this.integerDecoration, + this.decimalDecoration, + }) : assert(minValue <= value), + assert(value <= maxValue), + super(key: key); + + @override + Widget build(BuildContext context) { + final isMax = value.floor() == maxValue; + final decimalValue = isMax + ? 0 + : ((value - value.floorToDouble()) * math.pow(10, decimalPlaces)) + .round(); + final doubleMaxValue = isMax ? 0 : math.pow(10, decimalPlaces).toInt() - 1; + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + NumberPicker( + minValue: minValue, + maxValue: maxValue, + value: value.floor(), + onChanged: _onIntChanged, + itemCount: itemCount, + itemHeight: itemHeight, + itemWidth: itemWidth, + textStyle: textStyle, + selectedTextStyle: selectedTextStyle, + haptics: haptics, + zeroPad: integerZeroPad, + textMapper: integerTextMapper, + decoration: integerDecoration, + ), + NumberPicker( + minValue: 0, + maxValue: doubleMaxValue, + value: decimalValue, + onChanged: _onDoubleChanged, + itemCount: itemCount, + itemHeight: itemHeight, + itemWidth: itemWidth, + textStyle: textStyle, + selectedTextStyle: selectedTextStyle, + haptics: haptics, + textMapper: decimalTextMapper, + decoration: decimalDecoration, + ), + ], + ); + } + + void _onIntChanged(int intValue) { + final newValue = + (value - value.floor() + intValue).clamp(minValue, maxValue); + onChanged(newValue.toDouble()); + } + + void _onDoubleChanged(int doubleValue) { + final decimalPart = double.parse( + (doubleValue * math.pow(10, -decimalPlaces)) + .toStringAsFixed(decimalPlaces)); + onChanged(value.floor() + decimalPart); + } +} diff --git a/lib/src/inputs/number_picker/infinite_listview.dart b/lib/src/inputs/number_picker/infinite_listview.dart new file mode 100644 index 0000000..e505666 --- /dev/null +++ b/lib/src/inputs/number_picker/infinite_listview.dart @@ -0,0 +1,366 @@ +// SPDX-FileCopyrightText: 2022 Iconica +// +// SPDX-License-Identifier: BSD-3-Clause + +library infinite_listview; + +import 'dart:math' as math; + +import 'package:flutter/gestures.dart' show DragStartBehavior; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; + +/// Infinite ListView +/// +/// ListView that builds its children with to an infinite extent. +/// +class InfiniteListView extends StatefulWidget { + /// See [ListView.builder] + const InfiniteListView.builder({ + Key? key, + this.scrollDirection = Axis.vertical, + this.reverse = false, + this.controller, + this.physics, + this.padding, + this.itemExtent, + required this.itemBuilder, + this.itemCount, + this.addAutomaticKeepAlives = true, + this.addRepaintBoundaries = true, + this.addSemanticIndexes = true, + this.cacheExtent, + this.anchor = 0.0, + this.dragStartBehavior = DragStartBehavior.start, + this.keyboardDismissBehavior = ScrollViewKeyboardDismissBehavior.manual, + this.restorationId, + this.clipBehavior = Clip.hardEdge, + }) : separatorBuilder = null, + super(key: key); + + /// See [ListView.separated] + const InfiniteListView.separated({ + Key? key, + this.scrollDirection = Axis.vertical, + this.reverse = false, + this.controller, + this.physics, + this.padding, + required this.itemBuilder, + required this.separatorBuilder, + this.itemCount, + this.addAutomaticKeepAlives = true, + this.addRepaintBoundaries = true, + this.addSemanticIndexes = true, + this.cacheExtent, + this.anchor = 0.0, + this.dragStartBehavior = DragStartBehavior.start, + this.keyboardDismissBehavior = ScrollViewKeyboardDismissBehavior.manual, + this.restorationId, + this.clipBehavior = Clip.hardEdge, + }) : itemExtent = null, + super(key: key); + + /// See: [ScrollView.scrollDirection] + final Axis scrollDirection; + + /// See: [ScrollView.reverse] + final bool reverse; + + /// See: [ScrollView.controller] + final InfiniteScrollController? controller; + + /// See: [ScrollView.physics] + final ScrollPhysics? physics; + + /// See: [BoxScrollView.padding] + final EdgeInsets? padding; + + /// See: [ListView.builder] + final IndexedWidgetBuilder itemBuilder; + + /// See: [ListView.separated] + final IndexedWidgetBuilder? separatorBuilder; + + /// See: [SliverChildBuilderDelegate.childCount] + final int? itemCount; + + /// See: [ListView.itemExtent] + final double? itemExtent; + + /// See: [ScrollView.cacheExtent] + final double? cacheExtent; + + /// See: [ScrollView.anchor] + final double anchor; + + /// See: [SliverChildBuilderDelegate.addAutomaticKeepAlives] + final bool addAutomaticKeepAlives; + + /// See: [SliverChildBuilderDelegate.addRepaintBoundaries] + final bool addRepaintBoundaries; + + /// See: [SliverChildBuilderDelegate.addSemanticIndexes] + final bool addSemanticIndexes; + + /// See: [ScrollView.dragStartBehavior] + final DragStartBehavior dragStartBehavior; + + /// See: [ScrollView.keyboardDismissBehavior] + final ScrollViewKeyboardDismissBehavior keyboardDismissBehavior; + + /// See: [ScrollView.restorationId] + final String? restorationId; + + /// See: [ScrollView.clipBehavior] + final Clip clipBehavior; + + @override + InfiniteListViewState createState() => InfiniteListViewState(); +} + +class InfiniteListViewState extends State { + InfiniteScrollController? _controller; + + InfiniteScrollController get _effectiveController => + widget.controller ?? _controller!; + + @override + void initState() { + super.initState(); + if (widget.controller == null) { + _controller = InfiniteScrollController(); + } + } + + @override + void didUpdateWidget(InfiniteListView oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.controller == null && oldWidget.controller != null) { + _controller = InfiniteScrollController(); + } else if (widget.controller != null && oldWidget.controller == null) { + _controller!.dispose(); + _controller = null; + } + } + + @override + void dispose() { + _controller?.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final List slivers = _buildSlivers(context, negative: false); + final List negativeSlivers = _buildSlivers(context, negative: true); + final AxisDirection axisDirection = _getDirection(context); + final scrollPhysics = + widget.physics ?? const AlwaysScrollableScrollPhysics(); + return Scrollable( + axisDirection: axisDirection, + controller: _effectiveController, + physics: scrollPhysics, + viewportBuilder: (BuildContext context, ViewportOffset offset) { + return Builder(builder: (BuildContext context) { + /// Build negative [ScrollPosition] for the negative scrolling [Viewport]. + final state = Scrollable.of(context)!; + final negativeOffset = _InfiniteScrollPosition( + physics: scrollPhysics, + context: state, + initialPixels: -offset.pixels, + keepScrollOffset: _effectiveController.keepScrollOffset, + negativeScroll: true, + ); + + /// Keep the negative scrolling [Viewport] positioned to the [ScrollPosition]. + offset.addListener(() { + negativeOffset._forceNegativePixels(offset.pixels); + }); + + /// Stack the two [Viewport]s on top of each other so they move in sync. + return Stack( + children: [ + Viewport( + axisDirection: flipAxisDirection(axisDirection), + anchor: 1.0 - widget.anchor, + offset: negativeOffset, + slivers: negativeSlivers, + cacheExtent: widget.cacheExtent, + ), + Viewport( + axisDirection: axisDirection, + anchor: widget.anchor, + offset: offset, + slivers: slivers, + cacheExtent: widget.cacheExtent, + ), + ], + ); + }); + }, + ); + } + + AxisDirection _getDirection(BuildContext context) { + return getAxisDirectionFromAxisReverseAndDirectionality( + context, widget.scrollDirection, widget.reverse); + } + + List _buildSlivers(BuildContext context, {bool negative = false}) { + final itemExtent = widget.itemExtent; + final padding = widget.padding ?? EdgeInsets.zero; + return [ + SliverPadding( + padding: negative + ? padding - EdgeInsets.only(bottom: padding.bottom) + : padding - EdgeInsets.only(top: padding.top), + sliver: (itemExtent != null) + ? SliverFixedExtentList( + delegate: negative + ? negativeChildrenDelegate + : positiveChildrenDelegate, + itemExtent: itemExtent, + ) + : SliverList( + delegate: negative + ? negativeChildrenDelegate + : positiveChildrenDelegate, + ), + ) + ]; + } + + SliverChildDelegate get negativeChildrenDelegate { + return SliverChildBuilderDelegate( + (BuildContext context, int index) { + final separatorBuilder = widget.separatorBuilder; + if (separatorBuilder != null) { + final itemIndex = (-1 - index) ~/ 2; + return index.isOdd + ? widget.itemBuilder(context, itemIndex) + : separatorBuilder(context, itemIndex); + } else { + return widget.itemBuilder(context, -1 - index); + } + }, + childCount: widget.itemCount, + addAutomaticKeepAlives: widget.addAutomaticKeepAlives, + addRepaintBoundaries: widget.addRepaintBoundaries, + ); + } + + SliverChildDelegate get positiveChildrenDelegate { + final separatorBuilder = widget.separatorBuilder; + final itemCount = widget.itemCount; + return SliverChildBuilderDelegate( + (separatorBuilder != null) + ? (BuildContext context, int index) { + final itemIndex = index ~/ 2; + return index.isEven + ? widget.itemBuilder(context, itemIndex) + : separatorBuilder(context, itemIndex); + } + : widget.itemBuilder, + childCount: separatorBuilder == null + ? itemCount + : (itemCount != null ? math.max(0, itemCount * 2 - 1) : null), + addAutomaticKeepAlives: widget.addAutomaticKeepAlives, + addRepaintBoundaries: widget.addRepaintBoundaries, + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + .add(EnumProperty('scrollDirection', widget.scrollDirection)); + properties.add(FlagProperty('reverse', + value: widget.reverse, ifTrue: 'reversed', showName: true)); + properties.add(DiagnosticsProperty( + 'controller', widget.controller, + showName: false, defaultValue: null)); + properties.add(DiagnosticsProperty('physics', widget.physics, + showName: false, defaultValue: null)); + properties.add(DiagnosticsProperty( + 'padding', widget.padding, + defaultValue: null)); + properties.add( + DoubleProperty('itemExtent', widget.itemExtent, defaultValue: null)); + properties.add( + DoubleProperty('cacheExtent', widget.cacheExtent, defaultValue: null)); + } +} + +/// Same as a [ScrollController] except it provides [ScrollPosition] objects with infinite bounds. +class InfiniteScrollController extends ScrollController { + /// Creates a new [InfiniteScrollController] + InfiniteScrollController({ + double initialScrollOffset = 0.0, + bool keepScrollOffset = true, + String? debugLabel, + }) : super( + initialScrollOffset: initialScrollOffset, + keepScrollOffset: keepScrollOffset, + debugLabel: debugLabel, + ); + + @override + ScrollPosition createScrollPosition(ScrollPhysics physics, + ScrollContext context, ScrollPosition? oldPosition) { + return _InfiniteScrollPosition( + physics: physics, + context: context, + initialPixels: initialScrollOffset, + keepScrollOffset: keepScrollOffset, + oldPosition: oldPosition, + debugLabel: debugLabel, + ); + } +} + +class _InfiniteScrollPosition extends ScrollPositionWithSingleContext { + _InfiniteScrollPosition({ + required ScrollPhysics physics, + required ScrollContext context, + double? initialPixels = 0.0, + bool keepScrollOffset = true, + ScrollPosition? oldPosition, + String? debugLabel, + this.negativeScroll = false, + }) : super( + physics: physics, + context: context, + initialPixels: initialPixels, + keepScrollOffset: keepScrollOffset, + oldPosition: oldPosition, + debugLabel: debugLabel, + ); + + final bool negativeScroll; + + void _forceNegativePixels(double value) { + super.forcePixels(-value); + } + + @override + void saveScrollOffset() { + if (!negativeScroll) { + super.saveScrollOffset(); + } + } + + @override + void restoreScrollOffset() { + if (!negativeScroll) { + super.restoreScrollOffset(); + } + } + + @override + double get minScrollExtent => double.negativeInfinity; + + @override + double get maxScrollExtent => double.infinity; +} diff --git a/lib/src/inputs/number_picker/number_picker.dart b/lib/src/inputs/number_picker/number_picker.dart new file mode 100644 index 0000000..575fbd3 --- /dev/null +++ b/lib/src/inputs/number_picker/number_picker.dart @@ -0,0 +1,74 @@ +// SPDX-FileCopyrightText: 2022 Iconica +// +// SPDX-License-Identifier: BSD-3-Clause + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'number_picker_field.dart'; + +class FlutterFormInputNumberPicker extends ConsumerWidget { + const FlutterFormInputNumberPicker({ + Key? key, + Widget? label, + this.minValue = 0, + this.maxValue = 100, + this.onSaved, + this.onChanged, + this.initialValue, + this.validator, + }) : assert(minValue < maxValue), + super( + key: key, + ); + + final int minValue; + final int maxValue; + final Function(int?)? onSaved; + final String Function(int?)? validator; + final int? initialValue; + final Function(int?)? onChanged; + + @override + Widget build(BuildContext context, WidgetRef ref) { + return NumberPickerFormField( + minValue: minValue, + maxValue: maxValue, + onSaved: (value) => onSaved?.call(value), + validator: (value) => validator?.call(value), + onChanged: (value) => onChanged?.call(value), + initialValue: initialValue ?? 0, + ); + } +} + +class NumberPickerFormField extends FormField { + NumberPickerFormField({ + Key? key, + required FormFieldSetter onSaved, + required FormFieldValidator validator, + void Function(int value)? onChanged, + int initialValue = 0, + bool autovalidate = false, + int minValue = 0, + int maxValue = 100, + }) : super( + key: key, + onSaved: onSaved, + validator: validator, + initialValue: initialValue, + builder: (FormFieldState state) { + return NumberPicker( + minValue: minValue, + maxValue: maxValue, + value: initialValue, + onChanged: (int value) { + onChanged?.call(value); + + state.didChange(value); + }, + itemHeight: 35, + itemCount: 5, + ); + }); +} diff --git a/lib/src/inputs/number_picker/number_picker_field.dart b/lib/src/inputs/number_picker/number_picker_field.dart new file mode 100644 index 0000000..1f207bb --- /dev/null +++ b/lib/src/inputs/number_picker/number_picker_field.dart @@ -0,0 +1,309 @@ +// SPDX-FileCopyrightText: 2022 Iconica +// +// SPDX-License-Identifier: BSD-3-Clause + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'infinite_listview.dart'; + +typedef TextMapper = String Function(String numberText); + +class NumberPicker extends StatefulWidget { + /// Min value user can pick + final int minValue; + + /// Max value user can pick + final int maxValue; + + /// Currently selected value + final int value; + + /// Called when selected value changes + final ValueChanged onChanged; + + /// Specifies how many items should be shown - defaults to 3 + final int itemCount; + + /// Step between elements. Only for integer datePicker + /// Examples: + /// if step is 100 the following elements may be 100, 200, 300... + /// if min=0, max=6, step=3, then items will be 0, 3 and 6 + /// if min=0, max=5, step=3, then items will be 0 and 3. + final int step; + + /// height of single item in pixels + final double itemHeight; + + /// width of single item in pixels + final double itemWidth; + + /// Direction of scrolling + final Axis axis; + + /// Style of non-selected numbers. If null, it uses Theme's bodyText2 + final TextStyle? textStyle; + + /// Style of non-selected numbers. If null, it uses Theme's headline5 with accentColor + final TextStyle? selectedTextStyle; + + /// Whether to trigger haptic pulses or not + final bool haptics; + + /// Build the text of each item on the picker + final TextMapper? textMapper; + + /// Pads displayed integer values up to the length of maxValue + final bool zeroPad; + + /// Decoration to apply to central box where the selected value is placed + final Decoration? decoration; + + final bool infiniteLoop; + + const NumberPicker({ + Key? key, + required this.minValue, + required this.maxValue, + required this.value, + required this.onChanged, + this.itemCount = 3, + this.step = 1, + this.itemHeight = 50, + this.itemWidth = 100, + this.axis = Axis.vertical, + this.textStyle, + this.selectedTextStyle, + this.haptics = false, + this.decoration, + this.zeroPad = false, + this.textMapper, + this.infiniteLoop = false, + }) : assert(minValue <= value), + assert(value <= maxValue), + super(key: key); + + @override + NumberPickerState createState() => NumberPickerState(); +} + +class NumberPickerState extends State { + late ScrollController _scrollController; + + late int value; + + @override + void initState() { + super.initState(); + + value = widget.value; + + final initialOffset = (value - widget.minValue) ~/ widget.step * itemExtent; + if (widget.infiniteLoop) { + _scrollController = + InfiniteScrollController(initialScrollOffset: initialOffset); + } else { + _scrollController = ScrollController(initialScrollOffset: initialOffset); + } + _scrollController.addListener(_scrollListener); + } + + void _scrollListener() { + var indexOfMiddleElement = (_scrollController.offset / itemExtent).round(); + if (widget.infiniteLoop) { + indexOfMiddleElement %= itemCount; + } else { + indexOfMiddleElement = indexOfMiddleElement.clamp(0, itemCount - 1); + } + final intValueInTheMiddle = + _intValueFromIndex(indexOfMiddleElement + additionalItemsOnEachSide); + + if (value != intValueInTheMiddle) { + setState(() { + value = intValueInTheMiddle; + }); + + widget.onChanged(intValueInTheMiddle); + if (widget.haptics) { + HapticFeedback.selectionClick(); + } + } + Future.delayed( + const Duration(milliseconds: 100), + () => _maybeCenterValue(), + ); + } + + @override + void didUpdateWidget(NumberPicker oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.value != value) { + _maybeCenterValue(); + } + } + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + bool get isScrolling => _scrollController.position.isScrollingNotifier.value; + + double get itemExtent => + widget.axis == Axis.vertical ? widget.itemHeight : widget.itemWidth; + + int get itemCount => (widget.maxValue - widget.minValue) ~/ widget.step + 1; + + int get listItemsCount => itemCount + 2 * additionalItemsOnEachSide; + + int get additionalItemsOnEachSide => (widget.itemCount - 1) ~/ 2; + + @override + Widget build(BuildContext context) { + return SizedBox( + width: widget.axis == Axis.vertical + ? widget.itemWidth + : widget.itemCount * widget.itemWidth, + height: widget.axis == Axis.vertical + ? widget.itemCount * widget.itemHeight + : widget.itemHeight, + child: NotificationListener( + onNotification: (not) { + if (not.dragDetails?.primaryVelocity == 0) { + Future.microtask(() => _maybeCenterValue()); + } + return true; + }, + child: ScrollConfiguration( + behavior: ScrollConfiguration.of(context).copyWith(scrollbars: false), + child: Stack( + children: [ + Center( + child: Container( + width: 300, + height: 45, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(5), + color: const Color(0xFFD8D8D8).withOpacity(0.50), + ), + ), + ), + if (widget.infiniteLoop) + InfiniteListView.builder( + scrollDirection: widget.axis, + controller: _scrollController as InfiniteScrollController, + itemExtent: itemExtent, + itemBuilder: _itemBuilder, + padding: EdgeInsets.zero, + ) + else + ListView.builder( + itemCount: listItemsCount, + scrollDirection: widget.axis, + controller: _scrollController, + itemExtent: itemExtent, + itemBuilder: _itemBuilder, + padding: EdgeInsets.zero, + ), + _NumberPickerSelectedItemDecoration( + axis: widget.axis, + itemExtent: itemExtent, + decoration: widget.decoration, + ), + ], + ), + ), + ), + ); + } + + Widget _itemBuilder(BuildContext context, int index) { + final themeData = Theme.of(context); + final defaultStyle = widget.textStyle ?? themeData.textTheme.bodyText2; + final selectedStyle = widget.selectedTextStyle ?? + themeData.textTheme.headline5 + ?.copyWith(color: themeData.highlightColor); + + final valueFromIndex = _intValueFromIndex(index % itemCount); + final isExtra = !widget.infiniteLoop && + (index < additionalItemsOnEachSide || + index >= listItemsCount - additionalItemsOnEachSide); + final itemStyle = valueFromIndex == value ? selectedStyle : defaultStyle; + + final child = isExtra + ? const SizedBox.shrink() + : Text( + _getDisplayedValue(valueFromIndex), + style: itemStyle, + ); + + return Container( + width: widget.itemWidth, + height: widget.itemHeight, + alignment: Alignment.center, + child: child, + ); + } + + String _getDisplayedValue(int value) { + final text = widget.zeroPad + ? value.toString().padLeft(widget.maxValue.toString().length, '0') + : value.toString(); + if (widget.textMapper != null) { + return widget.textMapper!(text); + } else { + return text; + } + } + + int _intValueFromIndex(int index) { + index -= additionalItemsOnEachSide; + index %= itemCount; + return widget.minValue + index * widget.step; + } + + void _maybeCenterValue() { + if (_scrollController.hasClients && !isScrolling) { + int diff = value - widget.minValue; + int index = diff ~/ widget.step; + if (widget.infiniteLoop) { + final offset = _scrollController.offset + 0.5 * itemExtent; + final cycles = (offset / (itemCount * itemExtent)).floor(); + index += cycles * itemCount; + } + _scrollController.animateTo( + index * itemExtent, + duration: const Duration(milliseconds: 300), + curve: Curves.easeOutCubic, + ); + } + } +} + +class _NumberPickerSelectedItemDecoration extends StatelessWidget { + final Axis axis; + final double itemExtent; + final Decoration? decoration; + + const _NumberPickerSelectedItemDecoration({ + Key? key, + required this.axis, + required this.itemExtent, + required this.decoration, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Center( + child: IgnorePointer( + child: Container( + width: isVertical ? double.infinity : itemExtent, + height: isVertical ? itemExtent : double.infinity, + decoration: decoration, + ), + ), + ); + } + + bool get isVertical => axis == Axis.vertical; +} diff --git a/lib/src/inputs/slider/slider.dart b/lib/src/inputs/slider/slider.dart new file mode 100644 index 0000000..0713730 --- /dev/null +++ b/lib/src/inputs/slider/slider.dart @@ -0,0 +1,40 @@ +// SPDX-FileCopyrightText: 2022 Iconica +// +// SPDX-License-Identifier: BSD-3-Clause + +import 'package:flutter/material.dart'; +import 'package:flutter_input_library/src/inputs/slider/slider_field.dart'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +class FlutterFormInputSlider extends ConsumerWidget { + const FlutterFormInputSlider({ + Key? key, + this.minValue = 0, + this.maxValue = 100, + this.onSaved, + this.onChanged, + this.initialValue, + this.validator, + }) : assert(minValue < maxValue), + super( + key: key, + ); + + final int minValue; + final int maxValue; + final Function(double?)? onSaved; + final String Function(double?)? validator; + final double? initialValue; + final Function(double?)? onChanged; + + @override + Widget build(BuildContext context, WidgetRef ref) { + return SliderFormField( + onSaved: (value) => onSaved?.call(value), + validator: (value) => validator?.call(value), + onChanged: (value) => onChanged?.call(value), + initialValue: initialValue ?? 0.5, + ); + } +} diff --git a/lib/src/inputs/slider/slider_field.dart b/lib/src/inputs/slider/slider_field.dart new file mode 100644 index 0000000..b1eb6c1 --- /dev/null +++ b/lib/src/inputs/slider/slider_field.dart @@ -0,0 +1,30 @@ +// SPDX-FileCopyrightText: 2022 Iconica +// +// SPDX-License-Identifier: BSD-3-Clause + +import 'package:flutter/material.dart'; + +/// Creates a slider with the given input parameters +class SliderFormField extends FormField { + SliderFormField({ + Key? key, + required FormFieldSetter onSaved, + required FormFieldValidator validator, + void Function(double value)? onChanged, + double initialValue = 0.5, + }) : super( + key: key, + onSaved: onSaved, + validator: validator, + initialValue: initialValue, + builder: (FormFieldState state) { + return Slider( + value: state.value ?? initialValue, + onChanged: (double value) { + onChanged?.call(value); + + state.didChange(value); + }, + ); + }); +} diff --git a/lib/src/inputs/switch/switch.dart b/lib/src/inputs/switch/switch.dart new file mode 100644 index 0000000..f1cec21 --- /dev/null +++ b/lib/src/inputs/switch/switch.dart @@ -0,0 +1,38 @@ +// SPDX-FileCopyrightText: 2022 Iconica +// +// SPDX-License-Identifier: BSD-3-Clause + +import 'package:flutter/material.dart'; + +import 'package:flutter_input_library/src/inputs/switch/switch_field.dart'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +class FlutterFormInputSwitch extends ConsumerWidget { + final Widget? label; + final Function(bool?)? onSaved; + final String Function(bool?)? validator; + final Function(bool?)? onChanged; + final bool? initialValue; + + const FlutterFormInputSwitch({ + Key? key, + this.label, + this.onSaved, + this.validator, + this.onChanged, + this.initialValue = false, + }) : super( + key: key, + ); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return SwitchFormField( + onSaved: (value) => onSaved?.call(value), + onChanged: (value) => onChanged?.call(value), + validator: (value) => validator?.call(value), + initialValue: initialValue ?? false, + ); + } +} diff --git a/lib/src/inputs/switch/switch_field.dart b/lib/src/inputs/switch/switch_field.dart new file mode 100644 index 0000000..b01a755 --- /dev/null +++ b/lib/src/inputs/switch/switch_field.dart @@ -0,0 +1,63 @@ +// SPDX-FileCopyrightText: 2022 Iconica +// +// SPDX-License-Identifier: BSD-3-Clause + +import 'package:flutter/material.dart'; + +class SwitchFormField extends FormField { + SwitchFormField({ + Key? key, + required FormFieldSetter onSaved, + required FormFieldValidator validator, + bool initialValue = false, + bool autovalidate = false, + void Function(bool? value)? onChanged, + }) : super( + key: key, + onSaved: onSaved, + validator: validator, + initialValue: initialValue, + builder: (FormFieldState state) { + return SwitchWidget( + initialValue: initialValue, + state: state, + onChanged: onChanged, + ); + }); +} + +class SwitchWidget extends StatefulWidget { + const SwitchWidget({ + this.initialValue = false, + required this.state, + this.onChanged, + super.key, + }); + + final bool initialValue; + final FormFieldState state; + final void Function(bool? value)? onChanged; + + @override + State createState() => _SwitchWidgetState(); +} + +class _SwitchWidgetState extends State { + late bool value = widget.initialValue; + + @override + Widget build(BuildContext context) { + return Switch( + value: value, + onChanged: (bool value) { + widget.onChanged?.call(value); + + widget.state.didChange(value); + + setState(() { + this.value = value; + }); + }, + ); + } +} diff --git a/lib/src/inputs/text/password.dart b/lib/src/inputs/text/password.dart new file mode 100644 index 0000000..b09b00a --- /dev/null +++ b/lib/src/inputs/text/password.dart @@ -0,0 +1,58 @@ +// SPDX-FileCopyrightText: 2022 Iconica +// +// SPDX-License-Identifier: BSD-3-Clause + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// Generates a [TextFormField] for passwords. It requires a [FlutterFormInputController] +/// as the [controller] parameter and an optional [Widget] as [label] +class FlutterFormInputPassword extends ConsumerStatefulWidget { + final Widget? label; + final String? initialValue; + final Function(String?)? onSaved; + final String Function(String?)? validator; + final Function(String?)? onChanged; + final Function(String?)? onFieldSubmitted; + + const FlutterFormInputPassword({ + Key? key, + this.label, + this.initialValue, + this.onSaved, + this.validator, + this.onChanged, + this.onFieldSubmitted, + }) : super(key: key); + + @override + ConsumerState createState() => + _PasswordTextFieldState(); +} + +class _PasswordTextFieldState extends ConsumerState { + bool obscured = true; + + @override + Widget build(BuildContext context) { + return TextFormField( + initialValue: widget.initialValue, + obscureText: obscured, + onSaved: (value) => widget.onSaved?.call(value), + validator: (value) => widget.validator?.call(value), + onChanged: (value) => widget.onChanged?.call(value), + onFieldSubmitted: (value) => widget.onFieldSubmitted?.call(value), + decoration: InputDecoration( + label: widget.label ?? const Text("Password"), + suffixIcon: IconButton( + onPressed: () { + setState(() { + obscured = !obscured; + }); + }, + icon: Icon(obscured ? Icons.visibility_off : Icons.visibility), + ), + ), + ); + } +} diff --git a/lib/src/inputs/text/plain_text.dart b/lib/src/inputs/text/plain_text.dart new file mode 100644 index 0000000..95d2652 --- /dev/null +++ b/lib/src/inputs/text/plain_text.dart @@ -0,0 +1,132 @@ +// SPDX-FileCopyrightText: 2022 Iconica +// +// SPDX-License-Identifier: BSD-3-Clause + +import 'package:flutter/material.dart'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +class FlutterFormInputPlainText extends ConsumerWidget { + const FlutterFormInputPlainText({ + Key? key, + this.label, + this.decoration, + this.textAlignVertical, + this.expands = false, + this.maxLines = 1, + this.scrollPadding, + this.maxLength, + this.keyboardType, + this.initialValue, + this.onChanged, + this.onSaved, + this.validator, + this.onFieldSubmitted, + }) : super( + key: key, + ); + + final InputDecoration? decoration; + final TextAlignVertical? textAlignVertical; + final bool expands; + final int? maxLines; + final int? maxLength; + final EdgeInsets? scrollPadding; + final TextInputType? keyboardType; + final Widget? label; + final String? initialValue; + final Function(String?)? onSaved; + final String Function(String?)? validator; + final Function(String?)? onChanged; + final Function(String?)? onFieldSubmitted; + + @override + Widget build(BuildContext context, WidgetRef ref) { + InputDecoration inputDecoration = decoration ?? + InputDecoration( + label: label ?? const Text("Plain text"), + ); + + return TextFormField( + scrollPadding: scrollPadding ?? const EdgeInsets.all(20.0), + initialValue: initialValue, + onSaved: (value) => onSaved?.call(value), + validator: (value) => validator?.call(value), + onChanged: (value) => onChanged?.call(value), + onFieldSubmitted: (value) => onFieldSubmitted?.call(value), + decoration: inputDecoration, + textAlignVertical: textAlignVertical, + expands: expands, + maxLines: maxLines, + maxLength: maxLength, + keyboardType: keyboardType, + ); + } +} + +class FlutterFormInputMultiLine extends StatelessWidget { + const FlutterFormInputMultiLine({ + Key? key, + this.label, + this.hint, + this.maxCharacters, + this.scrollPadding, + this.keyboardType, + this.initialValue, + this.decoration, + this.onChanged, + this.onSaved, + this.validator, + this.onFieldSubmitted, + }) : super(key: key); + + final Widget? label; + final String? hint; + final int? maxCharacters; + + final InputDecoration? decoration; + final EdgeInsets? scrollPadding; + final TextInputType? keyboardType; + final String? initialValue; + final Function(String?)? onSaved; + final String Function(String?)? validator; + final Function(String?)? onChanged; + final Function(String?)? onFieldSubmitted; + + @override + Widget build(BuildContext context) { + return Column( + children: [ + Expanded( + child: FlutterFormInputPlainText( + label: label, + textAlignVertical: TextAlignVertical.top, + expands: true, + maxLines: null, + maxLength: maxCharacters, + initialValue: initialValue, + scrollPadding: scrollPadding, + keyboardType: keyboardType, + onSaved: onSaved, + validator: validator, + onChanged: onChanged, + onFieldSubmitted: onFieldSubmitted, + decoration: decoration ?? + InputDecoration( + hintText: hint, + floatingLabelBehavior: FloatingLabelBehavior.never, + isDense: true, + border: const OutlineInputBorder( + borderSide: BorderSide(color: Color(0xFF979797)), + ), + focusedBorder: const OutlineInputBorder( + borderSide: BorderSide(color: Color(0xFF979797)), + ), + filled: true, + ), + ), + ), + ], + ); + } +} diff --git a/lib/src/utils/utils.dart b/lib/src/utils/utils.dart new file mode 100644 index 0000000..4c2dd46 --- /dev/null +++ b/lib/src/utils/utils.dart @@ -0,0 +1,5 @@ +// SPDX-FileCopyrightText: 2022 Iconica +// +// SPDX-License-Identifier: BSD-3-Clause + +export 'validators/validators.dart'; diff --git a/lib/src/utils/validators/email/email.dart b/lib/src/utils/validators/email/email.dart new file mode 100644 index 0000000..a8b613c --- /dev/null +++ b/lib/src/utils/validators/email/email.dart @@ -0,0 +1,15 @@ +// SPDX-FileCopyrightText: 2022 Iconica +// +// SPDX-License-Identifier: BSD-3-Clause + +class EmailValidator { + static bool isValid(String? email) { + if (email == null) { + return false; + } + + return RegExp( + r"""(?:[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*|"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\[(?:(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9]))\.){3}(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9])|[a-z0-9-]*[a-z0-9]:(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21-\x5a\x53-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])+)\])""", + ).hasMatch(email); + } +} diff --git a/lib/src/utils/validators/validators.dart b/lib/src/utils/validators/validators.dart new file mode 100644 index 0000000..378084d --- /dev/null +++ b/lib/src/utils/validators/validators.dart @@ -0,0 +1,5 @@ +// SPDX-FileCopyrightText: 2022 Iconica +// +// SPDX-License-Identifier: BSD-3-Clause + +export 'email/email.dart'; diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..52ff767 --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,56 @@ +name: flutter_input_library +description: A new Flutter package project. +version: 0.0.1 +repository: https://github.com/Iconica-Development/flutter_input_library + +environment: + sdk: '>=2.18.2 <3.0.0' + flutter: ">=1.17.0" + +dependencies: + flutter: + sdk: flutter + flutter_riverpod: any + intl: any + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^2.0.0 + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter packages. +flutter: + + # To add assets to your package, add an assets section, like this: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + # + # For details regarding assets in packages, see + # https://flutter.dev/assets-and-images/#from-packages + # + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/assets-and-images/#resolution-aware + + # To add custom fonts to your package, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts in packages, see + # https://flutter.dev/custom-fonts/#from-packages