diff --git a/CHANGELOG.md b/CHANGELOG.md index 41cc7d8..d7a448f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,3 @@ -## 0.0.1 +## 0.0.1 - September 29th 2022 -* TODO: Describe initial release. +- Initial release diff --git a/README.md b/README.md index 02fe8ec..325c1fa 100644 --- a/README.md +++ b/README.md @@ -1,39 +1,60 @@ - - -TODO: Put a short description of the package here that helps potential users -know whether this package might be useful for them. +Flutter Form is a package you can use to create a single or multi page form with premade or custom inputfields. ## Features -TODO: List what your package can do. Maybe include images, gifs, or videos. +- Single or multi page form with the ability to define the navigational buttons. +- A handfull premade fields with their own controllers. +- Full posibilty to create custom inputfields and controllers which can be used along side the premade fields and controllers. +- A checkpage where the end user can check his answers and jump back to the page of an inputfield to change his answer without going through the whole form. +- The look of the checkpage answers can be set own desire. -## Getting started +## Setup -TODO: List prerequisites and provide or point to information on how to -start using the package. +To use this package, add `flutter_form` as a [dependency in your pubspec.yaml file](https://flutter.dev/docs/development/platform-integration/platform-channels). -## Usage +## How To Use -TODO: Include short and useful examples for package users. Add longer examples -to `/example` folder. +See the [Example Code](example/lib/form_example.dart) for an example on how to use this package. -```dart -const like = 'sample'; -``` +WARNING Make sure to define your FlutterFormInputControllers above your Flutter Form and not inside each page. This prevents that the used controllers differ from the registered ones. -## Additional information +Flutter Form has two paramaters: options and formController. Each of these parameters' own parameters will be explained in tabels below. -TODO: Tell users more about the package: where to find more information, how to -contribute to the package, how to file issues, what response they can expect -from the package authors, and more. +Options: + +| Parameter | Explaination | +| ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| checkPage | If this is set the form will feature a checkpage at the end so the end user can verify and alter his answers. | +| nextButton | The button which is put in the stack of the Form. An onTap has to be implemented and should call to the FormController. Standard call is autoNextStep(). | +| backButton | Same as the nextButton. A widget that is put in the stack of the Form. An onTap has to be implemented and should call to the FormController. Standard call is previousStep(). | +| onFinised | The callback that will be called when the last page is finished. If checkPage is enabled this will call after the checkPage is passed. | +| onNext | The callback that is called when the user finishes a page. PageNumber is also provided. | + +FormController: + +| Parameter | Explaination | +| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------- | +| getFormPageControllers() | The getter to get all FormPageControllers. This should not be needed/called. | +| setFormPageControllers() | The setter for the FormPageControllers. This shoudl not be needed/called. | +| disableCheckPages() | This should be called when the user goes back to a page where the user alters an answer that alters the rest of the form. | +| autoNextStep() | This should be called under the nextButton of the FormOptions if no special actions are required. | +| previousStep() | This should be called under the backButton of the FormOptions. | +| jumpToPage() | A way to jump to a different page if desired. | +| validateAndSaveCurretnStep() | Calling the validate, and possibly save, for the current step. Returns the result of the validate. | +| getCurrentStepResults() | Get the result of the current step. Mostly called after validateAndSaveCurrentStep return true. | +| nextStep() | Called to go to the next step. This is does not do anything else like autoNextStep does do. | +| finishForm() | Calls the onFinished of the form options. | + +## Issues + +Please file any issues, bugs or feature request as an issue on our [GitHub](https://github.com/Iconica-Development/flutter_form/pulls) 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](URL TO PULL REQUEST TAB IN REPO). + +## Author + +`flutter-form` for Flutter is developed by [Iconica](https://iconica.nl). You can contact us at diff --git a/example/assets/images/BMW.png b/example/assets/images/BMW.png new file mode 100644 index 0000000..495d23c Binary files /dev/null and b/example/assets/images/BMW.png differ diff --git a/example/assets/images/Mazda.png b/example/assets/images/Mazda.png new file mode 100644 index 0000000..2540e6d Binary files /dev/null and b/example/assets/images/Mazda.png differ diff --git a/example/assets/images/Mercedes.png b/example/assets/images/Mercedes.png new file mode 100644 index 0000000..be9f2e4 Binary files /dev/null and b/example/assets/images/Mercedes.png differ diff --git a/example/lib/example_pages/age_page.dart b/example/lib/example_pages/age_page.dart new file mode 100644 index 0000000..83d6c93 --- /dev/null +++ b/example/lib/example_pages/age_page.dart @@ -0,0 +1,38 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_form/flutter_form.dart'; +import 'package:form_example/template_page.dart'; + +class AgePage extends StatefulWidget { + const AgePage({ + required this.inputController, + super.key, + }); + + final FlutterFormInputNumberPickerController inputController; + + @override + State createState() => _AgePageState(); +} + +class _AgePageState extends State { + @override + Widget build(BuildContext context) { + var size = MediaQuery.of(context).size; + var fontSize = size.height / 40; + + return TemplatePage( + size: size, + fontSize: fontSize, + title: "What is your age?", + pageNumber: 1, + amountOfPages: 3, + flutterFormWidgets: [ + FlutterFormInputNumberPicker( + minValue: 12, + maxValue: 120, + controller: widget.inputController, + ), + ], + ); + } +} diff --git a/example/lib/example_pages/carousel_page.dart b/example/lib/example_pages/carousel_page.dart new file mode 100644 index 0000000..ac763c4 --- /dev/null +++ b/example/lib/example_pages/carousel_page.dart @@ -0,0 +1,80 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_form/flutter_form.dart'; +import 'package:form_example/template_page.dart'; + +class CarouselPage extends StatefulWidget { + const CarouselPage({ + required this.inputController, + required this.cars, + super.key, + }); + + final FlutterFormInputCarouselController inputController; + final List> cars; + + @override + State createState() => _CarouselPageState(); +} + +class _CarouselPageState extends State { + @override + Widget build(BuildContext context) { + var size = MediaQuery.of(context).size; + var fontSize = size.height / 40; + + return TemplatePage( + size: size, + fontSize: fontSize, + title: "What's your favorite car?", + pageNumber: 3, + amountOfPages: 3, + flutterFormWidgets: [ + FlutterFormInputCarousel( + controller: widget.inputController, items: getCars()) + ], + ); + } + + List getCars() { + return widget.cars.map((car) { + return Builder( + builder: (BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Container( + width: MediaQuery.of(context).size.width, + decoration: const BoxDecoration( + color: Color(0xFFD8D8D8), + borderRadius: BorderRadius.all( + Radius.circular(10), + ), + ), + child: Image.asset('assets/images/${car['title']}.png'), + ), + ), + const SizedBox( + height: 14, + ), + Text( + car["title"], + style: const TextStyle( + fontWeight: FontWeight.w900, + fontSize: 20, + ), + ), + const SizedBox( + height: 5, + ), + Text( + car["description"], + style: const TextStyle(fontSize: 16), + ), + ], + ); + }, + ); + }).toList(); + } +} diff --git a/example/lib/example_pages/check_page.dart b/example/lib/example_pages/check_page.dart new file mode 100644 index 0000000..8dce2ff --- /dev/null +++ b/example/lib/example_pages/check_page.dart @@ -0,0 +1,92 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_form/flutter_form.dart'; + +class CheckPageExample { + CheckPage showCheckpage( + BuildContext context, + Size size, + double fontSize, + String checkPageText, + ) { + return CheckPage( + title: Container( + margin: const EdgeInsets.only( + top: 70, + bottom: 10, + ), + padding: const EdgeInsets.symmetric(horizontal: 40), + child: const Text( + "Check answers", + style: TextStyle( + fontSize: 25, + fontWeight: FontWeight.w900, + ), + ), + ), + inputCheckWidget: + (String title, String? description, Function onPressed) { + return GestureDetector( + onTap: () async { + await onPressed(); + }, + child: Container( + width: MediaQuery.of(context).size.width * 0.9, + padding: const EdgeInsets.only( + top: 18, + bottom: 16, + right: 18, + left: 27, + ), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(10), + boxShadow: [ + BoxShadow( + color: const Color(0xFF000000).withOpacity(0.20), + blurRadius: 5, + ), + ], + ), + child: Column( + children: [ + Row( + children: [ + Container( + width: 30, + height: 30, + decoration: BoxDecoration( + color: const Color(0xFFD8D8D8), + borderRadius: BorderRadius.circular(5), + ), + ), + const SizedBox( + width: 16, + ), + Text( + title, + style: const TextStyle( + fontWeight: FontWeight.w900, + fontSize: 20, + ), + ), + const Spacer(), + const Icon(Icons.arrow_forward), + ], + ), + if (description != null) + const SizedBox( + height: 9, + ), + if (description != null) + Text( + description, + style: const TextStyle(fontSize: 16), + ) + ], + ), + ), + ); + }, + ); + } +} diff --git a/example/lib/example_pages/name_page.dart b/example/lib/example_pages/name_page.dart new file mode 100644 index 0000000..0b8edcf --- /dev/null +++ b/example/lib/example_pages/name_page.dart @@ -0,0 +1,52 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_form/flutter_form.dart'; +import 'package:form_example/template_page.dart'; + +class NamePage extends StatefulWidget { + const NamePage({ + required this.firstNameController, + required this.lastNameController, + required this.showLastName, + super.key, + }); + + final FlutterFormInputPlainTextController firstNameController; + final FlutterFormInputPlainTextController lastNameController; + final bool showLastName; + + @override + State createState() => _NamePageState(); +} + +class _NamePageState extends State { + @override + Widget build(BuildContext context) { + var size = MediaQuery.of(context).size; + var fontSize = size.height / 40; + + return TemplatePage( + size: size, + fontSize: fontSize, + pageNumber: 2, + amountOfPages: 3, + title: "Please enter your name", + flutterFormWidgets: [ + Padding( + padding: const EdgeInsets.fromLTRB(40, 0, 40, 40), + child: FlutterFormInputPlainText( + label: const Text("First Name"), + controller: widget.firstNameController, + ), + ), + if (widget.showLastName) + Padding( + padding: const EdgeInsets.fromLTRB(40, 0, 40, 0), + child: FlutterFormInputPlainText( + label: const Text("Last Name"), + controller: widget.lastNameController, + ), + ), + ], + ); + } +} diff --git a/example/lib/example_pages/thanks_page.dart b/example/lib/example_pages/thanks_page.dart new file mode 100644 index 0000000..813e558 --- /dev/null +++ b/example/lib/example_pages/thanks_page.dart @@ -0,0 +1,28 @@ +import 'package:flutter/material.dart'; + +class ThanksPage extends StatelessWidget { + const ThanksPage({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text( + "Thanks for filling in the form!", + style: TextStyle(fontSize: 20), + ), + const SizedBox( + height: 20, + ), + ElevatedButton( + onPressed: () => Navigator.of(context).pushNamed('/'), + child: const Text("Next")) + ], + ), + ), + ); + } +} diff --git a/example/lib/form_example.dart b/example/lib/form_example.dart new file mode 100644 index 0000000..0d7c025 --- /dev/null +++ b/example/lib/form_example.dart @@ -0,0 +1,201 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_form/flutter_form.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:form_example/example_pages/age_page.dart'; +import 'package:form_example/example_pages/carousel_page.dart'; +import 'package:form_example/example_pages/check_page.dart'; +import 'package:form_example/example_pages/name_page.dart'; + +class FormExample extends ConsumerStatefulWidget { + const FormExample({Key? key}) : super(key: key); + + @override + ConsumerState createState() => _FormExampleState(); +} + +class _FormExampleState extends ConsumerState { + final FlutterFormController formController = FlutterFormController(); + + final String checkPageText = "All entered info: "; + + final ageInputController = FlutterFormInputNumberPickerController( + id: "age", + checkPageTitle: (dynamic amount) { + return "Age: $amount years"; + }, + ); + + late final FlutterFormInputCarouselController carouselInputController; + + final List> cars = [ + { + "title": "Mercedes", + "description": "Mercedes is a car", + }, + { + "title": "BMW", + "description": "BMW is a car", + }, + { + "title": "Mazda", + 'description': "Mazda is a car", + }, + ]; + + FlutterFormInputPlainTextController firstNameController = + FlutterFormInputPlainTextController( + mandatory: true, + id: "firstName", + checkPageTitle: (dynamic firstName) { + return "First Name: $firstName"; + }, + ); + + FlutterFormInputPlainTextController lastNameController = + FlutterFormInputPlainTextController( + mandatory: true, + id: "lastName", + checkPageTitle: (dynamic lastName) { + return "Last Name: $lastName"; + }, + ); + + @override + void initState() { + super.initState(); + carouselInputController = FlutterFormInputCarouselController( + id: 'carCarousel', + checkPageTitle: (dynamic index) { + return cars[index]["title"]; + }, + checkPageDescription: (dynamic index) { + return cars[index]["description"]; + }, + ); + } + + bool showLastName = true; + + @override + Widget build(BuildContext context) { + var size = MediaQuery.of(context).size; + var fontSize = size.height / 40; + + return GestureDetector( + onTap: () => FocusScope.of(context).unfocus(), + child: Scaffold( + body: Center( + child: FlutterForm( + formController: formController, + options: FlutterFormOptions( + onFinished: (Map> results) { + debugPrint("Final full results: $results"); + Navigator.of(context).pushNamed('/thanks'); + }, + onNext: (int pageNumber, Map results) { + debugPrint("Results page $pageNumber: $results"); + + if (pageNumber == 0) { + if (results['age'] >= 18) { + if (showLastName == false) { + showLastName = true; + formController.disableCheckingPages(); + } + } else { + if (showLastName == true) { + showLastName = false; + formController.disableCheckingPages(); + } + } + setState(() {}); + } + }, + nextButton: (int pageNumber, bool checkingPages) { + return Align( + alignment: Alignment.bottomCenter, + child: Padding( + padding: EdgeInsets.only( + bottom: size.height * 0.05, + ), + child: SizedBox( + height: size.height * 0.07, + width: size.width * 0.7, + child: ElevatedButton( + style: ElevatedButton.styleFrom( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(5), + ), + backgroundColor: Colors.black, + textStyle: TextStyle( + fontSize: fontSize, + fontWeight: FontWeight.w600, + color: Colors.white, + ), + ), + onPressed: () async { + await formController.autoNextStep(); + }, + child: Text(checkingPages ? "Save" : "Next Page"), + ), + ), + ), + ); + }, + backButton: (int pageNumber, bool checkingPages, int pageAmount) { + if (pageNumber != 0) { + if (!checkingPages || pageNumber >= pageAmount) { + return Align( + alignment: Alignment.topLeft, + child: Container( + margin: EdgeInsets.only( + top: size.height * 0.045, + left: size.width * 0.07, + ), + width: size.width * 0.08, + height: size.width * 0.08, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(90), + color: const Color(0xFFD8D8D8).withOpacity(0.50), + ), + child: IconButton( + padding: EdgeInsets.zero, + splashRadius: size.width * 0.06, + onPressed: () { + formController.previousStep(); + }, + icon: const Icon(Icons.chevron_left), + )), + ); + } + } + return Container(); + }, + pages: [ + FlutterFormPage( + child: AgePage( + inputController: ageInputController, + ), + ), + FlutterFormPage( + child: NamePage( + firstNameController: firstNameController, + lastNameController: lastNameController, + showLastName: showLastName, + ), + ), + FlutterFormPage( + child: CarouselPage( + inputController: carouselInputController, + cars: cars, + ), + ), + ], + checkPage: CheckPageExample() + .showCheckpage(context, size, fontSize, checkPageText), + ), + ), + ), + ), + ); + } +} diff --git a/example/lib/main.dart b/example/lib/main.dart index 68d517e..4b9341b 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -1,7 +1,9 @@ import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:form_example/route.dart'; void main() { - runApp(const FormsExample()); + runApp(const ProviderScope(child: FormsExample())); } class FormsExample extends StatelessWidget { @@ -15,7 +17,22 @@ class FormsExample extends StatelessWidget { theme: ThemeData( primarySwatch: Colors.blue, ), - home: const FormsHomePage(title: 'Flutter Demo Home Page'), + home: const FormsHomePage(title: 'Flutter Forms'), + initialRoute: '/', + onGenerateRoute: (settings) { + var routes = getRoutes(); + if (routes.containsKey(settings.name)) { + return PageRouteBuilder( + pageBuilder: (_, __, ___) => routes[settings.name]!(context), + settings: settings, + ); + } else { + return PageRouteBuilder( + settings: settings, + pageBuilder: (_, __, ___) => const Text('Page not found'), + ); + } + }, ); } } @@ -38,12 +55,10 @@ class _FormsHomePageState extends State { ), body: Center( child: ElevatedButton( - onPressed: (() => createForm()), + onPressed: (() => Navigator.of(context).pushNamed('/form')), child: const Text('Create form'), ), ), ); } - - void createForm() {} } diff --git a/example/lib/route.dart b/example/lib/route.dart new file mode 100644 index 0000000..5ef8b8c --- /dev/null +++ b/example/lib/route.dart @@ -0,0 +1,12 @@ +import 'package:flutter/widgets.dart'; +import 'package:form_example/example_pages/thanks_page.dart'; +import 'package:form_example/form_example.dart'; +import 'package:form_example/main.dart'; + +Map getRoutes() { + return { + '/': (context) => const FormsExample(), + '/form': (context) => const FormExample(), + '/thanks': (context) => const ThanksPage(), + }; +} diff --git a/example/lib/template_page.dart b/example/lib/template_page.dart new file mode 100644 index 0000000..c84c070 --- /dev/null +++ b/example/lib/template_page.dart @@ -0,0 +1,66 @@ +import 'package:flutter/material.dart'; + +class TemplatePage extends StatelessWidget { + const TemplatePage({ + super.key, + required this.size, + required this.fontSize, + required this.title, + required this.pageNumber, + required this.amountOfPages, + required this.flutterFormWidgets, + }); + + final Size size; + final double fontSize; + final String title; + final int pageNumber; + final int amountOfPages; + final List flutterFormWidgets; + + @override + Widget build(BuildContext context) { + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Align( + alignment: Alignment.centerLeft, + child: Padding( + padding: EdgeInsets.symmetric(horizontal: size.width / 10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + height: size.height / 10, + ), + Text( + "$pageNumber / $amountOfPages", + style: TextStyle( + fontSize: fontSize, + ), + ), + SizedBox( + height: size.height / 80, + ), + Text( + title, + style: TextStyle( + fontSize: fontSize, + fontWeight: FontWeight.w900, + ), + ), + ], + ), + ), + ), + const Spacer(), + for (var widget in flutterFormWidgets) ...[ + widget, + ], + const Spacer( + flex: 2, + ), + ], + ); + } +} diff --git a/example/pubspec.lock b/example/pubspec.lock index e12a987..c0b7486 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -36,6 +36,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.16.0" + crypto: + dependency: transitive + description: + name: crypto + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.2" cupertino_icons: dependency: "direct main" description: @@ -55,6 +62,13 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_form: + dependency: "direct main" + description: + path: ".." + relative: true + source: path + version: "0.0.1" flutter_lints: dependency: "direct dev" description: @@ -62,11 +76,30 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.0.1" + flutter_localizations: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + flutter_riverpod: + dependency: "direct main" + description: + name: flutter_riverpod + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.4" flutter_test: dependency: "direct dev" description: flutter source: sdk version: "0.0.0" + intl: + dependency: transitive + description: + name: intl + url: "https://pub.dartlang.org" + source: hosted + version: "0.17.0" lints: dependency: transitive description: @@ -74,6 +107,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.0.0" + localization: + dependency: transitive + description: + name: localization + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.0" matcher: dependency: transitive description: @@ -102,11 +142,25 @@ packages: 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: "1.0.3" sky_engine: dependency: transitive description: flutter source: sdk version: "0.0.99" + sliding_up_panel: + dependency: transitive + description: + name: sliding_up_panel + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.0+1" source_span: dependency: transitive description: @@ -121,6 +175,13 @@ packages: 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: @@ -149,6 +210,20 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "0.4.12" + typed_data: + dependency: transitive + description: + name: typed_data + url: "https://pub.dartlang.org" + source: hosted + version: "1.3.1" + uuid: + dependency: transitive + description: + name: uuid + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.6" vector_math: dependency: transitive description: @@ -158,3 +233,4 @@ packages: version: "2.1.2" sdks: dart: ">=2.18.0 <3.0.0" + flutter: ">=3.0.0" diff --git a/example/pubspec.yaml b/example/pubspec.yaml index 4cf460e..c13fe9e 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -1,91 +1,30 @@ -name: example -description: A new Flutter project. +name: form_example +description: Form example made with Flutter Form Package. -# 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 +publish_to: 'none' -# 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.0 <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 - - # The following adds the Cupertino Icons font to your application. - # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.2 + flutter_riverpod: ^1.0.4 + flutter_form: + path: ../ + 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 + assets: + - assets/images/ \ No newline at end of file diff --git a/example/test/widget_test.dart b/example/test/widget_test.dart deleted file mode 100644 index 860e27a..0000000 --- a/example/test/widget_test.dart +++ /dev/null @@ -1,30 +0,0 @@ -// This is a basic Flutter widget test. -// -// To perform an interaction with a widget in your test, use the WidgetTester -// utility in the flutter_test package. For example, you can send tap and scroll -// gestures. You can also use WidgetTester to find child widgets in the widget -// tree, read text, and verify that the values of widget properties are correct. - -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import 'package:example/main.dart'; - -void main() { - testWidgets('Counter increments smoke test', (WidgetTester tester) async { - // Build our app and trigger a frame. - await tester.pumpWidget(const FormsExample()); - - // Verify that our counter starts at 0. - expect(find.text('0'), findsOneWidget); - expect(find.text('1'), findsNothing); - - // Tap the '+' icon and trigger a frame. - await tester.tap(find.byIcon(Icons.add)); - await tester.pump(); - - // Verify that our counter has incremented. - expect(find.text('0'), findsNothing); - expect(find.text('1'), findsOneWidget); - }); -} diff --git a/lib/flutter_form.dart b/lib/flutter_form.dart index 2dea026..5c1a1cb 100644 --- a/lib/flutter_form.dart +++ b/lib/flutter_form.dart @@ -1,7 +1,4 @@ -library flutter_form; - -/// A Calculator. -class Calculator { - /// Returns [value] plus 1. - int addOne(int value) => value + 1; -} +export 'src/form.dart'; +export 'src/widgets/input/abstractions.dart'; +export 'src/widgets/input/input_types/input_types.dart'; +export 'utils/form.dart'; diff --git a/lib/src/form.dart b/lib/src/form.dart new file mode 100644 index 0000000..bcefc97 --- /dev/null +++ b/lib/src/form.dart @@ -0,0 +1,555 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_form/utils/translation_service.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../flutter_form.dart'; +import 'utils/form_page_controller.dart'; +import 'utils/formstate.dart' as fs; + +/// A wrapper for flutters [Form] that can be controlled by a controller and provides multiple pre-defined input types/fields +/// [FlutterForm] also provides multi page forms and a check page for validation. +/// +/// A [FlutterFormController] has to be given to control what happens to values and pages within the FlutterForm. +/// +/// [FlutterFormOptions] have to be provided to control the appearance of the form. +/// +/// WARNING Define your FormInputController above your FlutterForm. Otherwise when rebuild the controller will differ from the registered ones. +/// ``` dart +/// FlutterFormInputEmailController emailController = +/// FlutterFormInputEmailController(id: 'email'); +/// FlutterFormInputPasswordController passwordController = +/// FlutterFormInputPasswordController(id: 'password'); +/// +/// FlutterForm( +/// formController: FlutterFormController, +/// options: FlutterFormOptions( +/// onFinished: (Map> results) { +/// // print(results); +/// }, +/// onNext: (int pageNumber, Map results) { +/// // print("Results page $pageNumber: $results"); +/// }, +/// nextButton: (int pageNumber, bool checkingPages) { +/// return Align( +/// alignment: Alignment.bottomCenter, +/// child: Padding( +/// padding: const EdgeInsets.only( +/// bottom: 25, +/// ), +/// child: ElevatedButton( +/// onPressed: () { +/// FlutterFormController.autoNextStep(); +/// }, +/// child: Text(checkingPages ? "Save" : "Next Page"), +/// ), +/// ), +/// ); +/// }, +/// backButton: (int pageNumber, bool checkingPages, int pageAmount) { +/// if (pageNumber != 0) { +/// if (!checkingPages || pageNumber >= pageAmount) { +/// return Align( +/// alignment: Alignment.topLeft, +/// child: IconButton( +/// padding: EdgeInsets.zero, +/// splashRadius: 29, +/// onPressed: () { +/// FlutterFormController.previousStep(); +/// }, +/// icon: const Icon(Icons.chevron_left), +/// ), +/// ); +/// } +/// } +/// return Container(); +/// }, +/// pages: [ +/// FlutterFormPage( +/// child: Column( +/// mainAxisAlignment: MainAxisAlignment.center, +/// children: [ +/// Align( +/// alignment: Alignment.centerLeft, +/// child: Padding( +/// padding: const EdgeInsets.symmetric(horizontal: 46), +/// child: Column( +/// crossAxisAlignment: CrossAxisAlignment.start, +/// children: const [ +/// SizedBox( +/// height: 60, +/// ), +/// Text( +/// 'Inloggen', +/// style: TextStyle( +/// fontSize: 25, +/// fontWeight: FontWeight.w900, +/// ), +/// ), +/// ], +/// ), +/// ), +/// ), +/// const Spacer(), +/// FlutterFormInputEmail(controller: emailController), +/// const SizedBox( +/// height: 25, +/// ), +/// FlutterFormInputPassword(controller: passwordController), +/// const Spacer(), +/// ], +/// ), +/// ), +/// ], +/// checkPage: CheckPage( +/// title: const Text( +/// "All entered info: ", +/// style: TextStyle( +/// fontSize: 25, +/// fontWeight: FontWeight.w900, +/// ), +/// ), +/// inputCheckWidget: +/// (String title, String? description, Function onPressed) { +/// return GestureDetector( +/// onTap: () async { +/// await onPressed(); +/// }, +/// child: Container( +/// width: MediaQuery.of(context).size.width * 0.9, +/// padding: const EdgeInsets.only( +/// top: 18, +/// bottom: 16, +/// right: 18, +/// left: 27, +/// ), +/// decoration: BoxDecoration( +/// color: Colors.white, +/// borderRadius: BorderRadius.circular(10), +//// boxShadow: [ +/// BoxShadow( +/// color: const Color(0xFF000000).withOpacity(0.20), +/// blurRadius: 5, +/// ), +/// ], +/// ), +/// child: Column( +/// children: [ +/// Row( +/// children: [ +/// Container( +/// width: 30, +/// height: 30, +/// decoration: BoxDecoration( +/// color: const Color(0xFFD8D8D8), +/// borderRadius: BorderRadius.circular(5), +/// ), +/// ), +/// const SizedBox( +/// width: 16, +/// ), +/// Text( +/// title, +/// style: const TextStyle( +/// fontWeight: FontWeight.w900, +/// fontSize: 20, +/// ), +/// ), +/// const Spacer(), +/// const Icon(Icons.arrow_forward), +/// ], +/// ), +/// if (description != null) +/// const SizedBox( +/// height: 9, +/// ), +/// if (description != null) +/// Text( +/// description, +/// style: const TextStyle(fontSize: 16), +/// ) +/// ], +/// ), +/// ), +/// ); +/// }, +/// mainAxisAlignment: MainAxisAlignment.start, +/// ), +/// ), +/// ), +/// ``` +class FlutterForm extends ConsumerStatefulWidget { + const FlutterForm({ + Key? key, + required this.options, + required this.formController, + }) : super(key: key); + + final FlutterFormOptions options; + final FlutterFormController formController; + + @override + ConsumerState createState() => _FlutterFormState(); +} + +class _FlutterFormState extends ConsumerState { + late FlutterFormController _formController; + + @override + void initState() { + super.initState(); + + _formController = widget.formController; + + _formController.setFlutterFormOptions(widget.options); + + List> keys = []; + + for (FlutterFormPage _ in widget.options.pages) { + keys.add(GlobalKey()); + } + + _formController.setKeys(keys); + + _formController.addListener(() { + setState(() {}); + }); + + List controllers = []; + + for (int i = 0; i < widget.options.pages.length; i++) { + controllers.add(FlutterFormPageController()); + } + + _formController.setFormPageControllers(controllers); + } + + @override + Widget build(BuildContext context) { + var _ = getTranslator(context, ref); + + return Stack( + children: [ + PageView( + controller: _formController.getPageController(), + physics: const NeverScrollableScrollPhysics(), + children: [ + for (int i = 0; i < widget.options.pages.length; i++) ...[ + Form( + key: _formController.getKeys()[i], + child: fs.FormState( + formController: _formController.getFormPageControllers()[i], + child: CustomScrollView( + slivers: [ + SliverFillRemaining( + hasScrollBody: false, + child: widget.options.pages[i].child, + ), + ], + ), + ), + ), + ], + if (widget.options.checkPage != null) + Column( + children: [ + if (widget.options.checkPage!.title != null) + widget.options.checkPage!.title!, + Expanded( + child: CustomScrollView( + slivers: [ + SliverFillRemaining( + hasScrollBody: false, + child: Column( + mainAxisAlignment: + widget.options.checkPage!.mainAxisAlignment, + children: getResultWidgets(), + ), + ), + ], + ), + ), + ], + ), + ], + ), + widget.options.nextButton != null + ? widget.options.nextButton!(_formController.getCurrentStep(), + _formController.getCheckpages()) + : Align( + alignment: AlignmentDirectional.bottomCenter, + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: Theme.of(context).primaryColor, + padding: const EdgeInsets.symmetric( + horizontal: 40, vertical: 15), + textStyle: const TextStyle( + fontSize: 20, fontWeight: FontWeight.bold)), + onPressed: () async { + await _formController.autoNextStep(); + }, + child: Text(_formController.getCurrentStep() >= + widget.options.pages.length - 1 + ? "Finish" + : "Next"), + ), + ), + if (widget.options.backButton != null) + widget.options.backButton!( + _formController.getCurrentStep(), + _formController.getCheckpages(), + widget.options.pages.length, + ), + ], + ); + } + + List getResultWidgets() { + List widgets = []; + + _formController.getAllResults().forEach( + (pageNumber, pageResults) { + pageResults.forEach((inputId, inputResult) { + FlutterFormInputController? inputController = _formController + .getFormPageControllers()[pageNumber] + .getController(inputId); + + if (inputController != null) { + if (widget.options.checkPage!.inputCheckWidget != null) { + widgets.add( + widget.options.checkPage!.inputCheckWidget!( + inputController.checkPageTitle != null + ? inputController.checkPageTitle!(inputController.value) + : inputController.value.toString(), + inputController.checkPageDescription != null + ? inputController + .checkPageDescription!(inputController.value) + : null, + () async { + await _formController.jumpToPage(pageNumber); + }, + ), + ); + } else { + widgets.add( + GestureDetector( + onTap: () async { + await _formController.jumpToPage(pageNumber); + }, + child: Column( + children: [ + Row( + children: [ + Container( + width: 30, + height: 30, + decoration: BoxDecoration( + color: const Color(0xFFD8D8D8), + borderRadius: BorderRadius.circular(5), + ), + ), + const SizedBox( + width: 16, + ), + Text( + inputController.checkPageTitle != null + ? inputController.checkPageTitle!(inputResult) + : inputResult.toString(), + style: const TextStyle( + fontWeight: FontWeight.w900, + fontSize: 20, + ), + ), + const Spacer(), + const Icon(Icons.arrow_forward), + ], + ), + if (inputController.checkPageDescription != null) + const SizedBox( + height: 9, + ), + if (inputController.checkPageDescription != null) + Text( + inputController.checkPageDescription!(inputResult), + style: const TextStyle(fontSize: 16), + ) + ], + ), + ), + ); + } + } + + widgets.add( + const SizedBox( + height: 25, + ), + ); + }); + }, + ); + + return widgets; + } +} + +class FlutterFormController extends ChangeNotifier { + late FlutterFormOptions _options; + + int _currentStep = 0; + + late List> _keys; + + bool _checkingPages = false; + + final PageController _pageController = PageController(); + + late List _formPageControllers; + + List getFormPageControllers() { + return _formPageControllers; + } + + setFormPageControllers(List controllers) { + _formPageControllers = controllers; + } + + disableCheckingPages() { + _checkingPages = false; + + for (var controller in _formPageControllers) { + controller.clearControllers(); + } + } + + Future autoNextStep() async { + if (_currentStep >= _options.pages.length && _options.checkPage != null) { + _options.onFinished(getAllResults()); + } else { + if (validateAndSaveCurrentStep()) { + FocusManager.instance.primaryFocus?.unfocus(); + + _options.onNext( + _currentStep, _formPageControllers[_currentStep].getAllValues()); + + if (_currentStep >= _options.pages.length - 1 && + _options.checkPage == null || + _currentStep >= _options.pages.length && + _options.checkPage != null) { + _options.onFinished(getAllResults()); + } else { + if (_checkingPages) { + _currentStep = _options.pages.length; + + notifyListeners(); + + await _pageController.animateToPage(_currentStep, + duration: const Duration(milliseconds: 250), + curve: Curves.ease); + } else { + _currentStep += 1; + + if (_currentStep >= _options.pages.length && + _options.checkPage != null) { + _checkingPages = true; + } + + notifyListeners(); + + await _pageController.animateToPage(_currentStep, + duration: const Duration(milliseconds: 250), + curve: Curves.ease); + } + } + } + } + } + + Future previousStep() async { + _currentStep -= 1; + + _checkingPages = false; + + notifyListeners(); + + await _pageController.animateToPage( + _currentStep, + duration: const Duration(milliseconds: 250), + curve: Curves.ease, + ); + } + + Future jumpToPage(int pageNumber) async { + _currentStep = pageNumber; + + notifyListeners(); + + await _pageController.animateToPage( + _currentStep, + duration: const Duration(milliseconds: 250), + curve: Curves.ease, + ); + } + + bool validateAndSaveCurrentStep() { + if (_keys[_currentStep].currentState!.validate()) { + _keys[_currentStep].currentState!.save(); + + return true; + } + + return false; + } + + Map getCurrentStepResults() { + return _formPageControllers[_currentStep].getAllValues(); + } + + Future nextStep() async { + _currentStep += 1; + + if (_currentStep >= _options.pages.length && _options.checkPage != null) { + _checkingPages = true; + } + + notifyListeners(); + + await _pageController.animateToPage(_currentStep, + duration: const Duration(milliseconds: 250), curve: Curves.ease); + } + + finishForm() { + _options.onFinished(getAllResults()); + } + + Map> getAllResults() { + Map> allValues = {}; + + for (var i = 0; i < _options.pages.length; i++) { + allValues.addAll({i: _formPageControllers[i].getAllValues()}); + } + return allValues; + } + + setFlutterFormOptions(FlutterFormOptions options) { + _options = options; + } + + setKeys(List> keys) { + _keys = keys; + } + + List> getKeys() { + return _keys; + } + + int getCurrentStep() { + return _currentStep; + } + + bool getCheckpages() { + return _checkingPages; + } + + PageController getPageController() { + return _pageController; + } +} diff --git a/lib/src/utils/form_page_controller.dart b/lib/src/utils/form_page_controller.dart new file mode 100644 index 0000000..2a1cd7b --- /dev/null +++ b/lib/src/utils/form_page_controller.dart @@ -0,0 +1,36 @@ +import 'package:flutter_form/flutter_form.dart'; + +class FlutterFormPageController { + List _controllers = []; + + void register(FlutterFormInputController inputController) { + _controllers.add(inputController); + } + + clearControllers() { + _controllers = []; + } + + bool _isRegisteredById(String id) { + return _controllers.any((element) => (element.id == id)); + } + + FlutterFormInputController? getController(String key) { + if (_isRegisteredById(key)) { + return _controllers.firstWhere((element) => element.id == key); + } + return null; + } + + Map getAllValues() { + Map values = {}; + + for (FlutterFormInputController controller in _controllers) { + if (controller.value != null) { + values.addAll({controller.id!: controller.value}); + } + } + + return values; + } +} diff --git a/lib/src/utils/formstate.dart b/lib/src/utils/formstate.dart new file mode 100644 index 0000000..afd0056 --- /dev/null +++ b/lib/src/utils/formstate.dart @@ -0,0 +1,23 @@ +import 'package:flutter/material.dart'; +import 'form_page_controller.dart'; + +class FormState extends InheritedWidget { + const FormState({ + Key? key, + required Widget child, + required this.formController, + }) : super(key: key, child: child); + + final FlutterFormPageController formController; + + static FormState of(BuildContext context) { + final FormState? result = + context.dependOnInheritedWidgetOfExactType(); + assert(result != null, 'No FormStat found in context'); + return result!; + } + + @override + bool updateShouldNotify(FormState oldWidget) => + formController != oldWidget.formController; +} diff --git a/lib/src/widgets/input/abstractions.dart b/lib/src/widgets/input/abstractions.dart new file mode 100644 index 0000000..097b229 --- /dev/null +++ b/lib/src/widgets/input/abstractions.dart @@ -0,0 +1,93 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '/src/utils/formstate.dart' as fs; + +/// Abstract class for the input widgets used in a [FlutterForm]. +/// +/// The controller [FlutterFormInputController] has to be given to the widget. +/// Which controller is used determines how to value will be handled. +/// +/// label is a standard parameter to normally sets the label of the input. +/// +/// [registerController] should be called to register the given [controller] to the form page. +abstract class FlutterFormInputWidget extends ConsumerWidget { + const FlutterFormInputWidget({ + Key? key, + required this.controller, + this.label, + String? hintText, + }) : super(key: key); + + /// The [controller] which determines how the value is handled and how the value is shown on the checkpage. + final FlutterFormInputController controller; + + /// [label] is a standard parameter to normally sets the label of the input. + final Widget? label; + + /// [registerController] should be called to register the given [controller] to the form page. + registerController(BuildContext context) { + FlutterFormInputController? localController = + fs.FormState.of(context).formController.getController(controller.id!); + + if (localController == null) { + fs.FormState.of(context).formController.register(controller); + } + } +} + +/// Abstract class for the controller for inputs used in a [FlutterForm]. +/// +/// The [id] determines the key in the [Map] returned by the [FlutterForm]. +/// +/// [value] is a way to set a initial value and will be the value when change by the user. +/// +/// [mandatory] determines if the input is mandatory. +/// +/// [checkPageTitle] is a function where you can transform the value from the input into something representable. +/// This value will be given when defining the check page widgets. +/// If this function is not set, the value will be used as is. +/// Example: +/// ``` dart +/// checkPageTitle: (dynamic amount) { +/// return "$amount persons"; +/// }, +/// ``` +/// +/// [checkPageDescription] is the same as checkPageTitle but for the description. +/// If null no description will be shown. +/// +/// [onSaved] goes of when the save function is called for the page if [onValidate] return null. +/// +/// [onValidate] is used to validate the given input by the user. +abstract class FlutterFormInputController { + /// The [id] determines the key in the [Map] returned by the [FlutterForm]. + String? id; + + /// [value] is a way to set a initial value and will be the value when change by the user. + T? value; + + /// [mandatory] determines if the input is mandatory. + bool mandatory = false; + + /// [checkPageTitle] is a function where you can transform the value from the input into something representable. + /// This value will be given when defining the check page widgets. + /// If this function is not set, the value will be used as is. + /// Example: + /// ``` dart + /// checkPageTitle: (dynamic amount) { + /// return "$amount persons"; + /// }, + /// ``` + String Function(T value)? checkPageTitle; + + /// [checkPageDescription] is the same as checkPageTitle but for the description. + /// If null no description will be shown. + String Function(T value)? checkPageDescription; + + /// [onSaved] goes of when the save function is called for the page if [onValidate] return null. + void onSaved(T value); + + /// [onValidate] is used to validate the given input by the user. + String? onValidate( + T value, String Function(String, {List? params}) translator); +} diff --git a/lib/src/widgets/input/input_types/input_carousel/carousel_controller.dart b/lib/src/widgets/input/input_types/input_carousel/carousel_controller.dart new file mode 100644 index 0000000..253b98a --- /dev/null +++ b/lib/src/widgets/input/input_types/input_carousel/carousel_controller.dart @@ -0,0 +1,146 @@ +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/widgets/input/input_types/input_carousel/carousel_form.dart b/lib/src/widgets/input/input_types/input_carousel/carousel_form.dart new file mode 100644 index 0000000..ed1b621 --- /dev/null +++ b/lib/src/widgets/input/input_types/input_carousel/carousel_form.dart @@ -0,0 +1,34 @@ +import 'package:flutter/material.dart'; +import 'carousel_slider.dart'; + +class CarouselFormField extends FormField { + CarouselFormField({ + Key? key, + required FormFieldSetter onSaved, + required FormFieldValidator validator, + int initialValue = 0, + bool autovalidate = false, + required List items, + }) : super( + key: key, + onSaved: onSaved, + validator: validator, + initialValue: initialValue, + builder: (FormFieldState state) { + return CarouselSlider( + options: CarouselOptions( + initialPage: initialValue, + onPageChanged: (index, reason) { + state.didChange(index); + }, + height: 425, + aspectRatio: 2.0, + enlargeCenterPage: true, + enableInfiniteScroll: false, + ), + items: items.map((Widget item) { + return item; + }).toList(), + ); + }); +} diff --git a/lib/src/widgets/input/input_types/input_carousel/carousel_options.dart b/lib/src/widgets/input/input_types/input_carousel/carousel_options.dart new file mode 100644 index 0000000..a88b1fa --- /dev/null +++ b/lib/src/widgets/input/input_types/input_carousel/carousel_options.dart @@ -0,0 +1,216 @@ +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/widgets/input/input_types/input_carousel/carousel_slider.dart b/lib/src/widgets/input/input_types/input_carousel/carousel_slider.dart new file mode 100644 index 0000000..c105e98 --- /dev/null +++ b/lib/src/widgets/input/input_types/input_carousel/carousel_slider.dart @@ -0,0 +1,354 @@ +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/widgets/input/input_types/input_carousel/carousel_state.dart b/lib/src/widgets/input/input_types/input_carousel/carousel_state.dart new file mode 100644 index 0000000..df2e01c --- /dev/null +++ b/lib/src/widgets/input/input_types/input_carousel/carousel_state.dart @@ -0,0 +1,41 @@ +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/widgets/input/input_types/input_carousel/carousel_utils.dart b/lib/src/widgets/input/input_types/input_carousel/carousel_utils.dart new file mode 100644 index 0000000..fb34f3d --- /dev/null +++ b/lib/src/widgets/input/input_types/input_carousel/carousel_utils.dart @@ -0,0 +1,23 @@ +/// 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/widgets/input/input_types/input_carousel/input_carousel.dart b/lib/src/widgets/input/input_types/input_carousel/input_carousel.dart new file mode 100644 index 0000000..2e9867d --- /dev/null +++ b/lib/src/widgets/input/input_types/input_carousel/input_carousel.dart @@ -0,0 +1,79 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_form/utils/translation_service.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_form/flutter_form.dart'; + +import 'carousel_form.dart'; + +/// Input for a carousel of items used in a [FlutterForm]. +/// +/// [items] will be the [Widget]s to be displayed in the carousel. +/// +/// Standard controller is [FlutterFormInputCarouselController]. +class FlutterFormInputCarousel extends FlutterFormInputWidget { + const FlutterFormInputCarousel({ + Key? key, + required FlutterFormInputController controller, + Widget? label, + required this.items, + }) : super(key: key, controller: controller, label: label); + + final List items; + + @override + Widget build(BuildContext context, WidgetRef ref) { + String Function(String, {List? params}) _ = + getTranslator(context, ref); + + super.registerController(context); + + return CarouselFormField( + onSaved: (value) => controller.onSaved(value), + validator: (value) => controller.onValidate(value, _), + initialValue: controller.value ?? 0, + items: items, + ); + } +} + +/// Controller for the carousel used by a [FlutterFormInputWidget] used in a [FlutterForm]. +/// +/// Mainly used by [FlutterFormInputCarousel]. +class FlutterFormInputCarouselController + implements FlutterFormInputController { + FlutterFormInputCarouselController({ + required this.id, + this.mandatory = true, + this.value, + this.checkPageTitle, + this.checkPageDescription, + }); + + @override + String? id; + + @override + int? value; + + @override + bool mandatory; + + @override + String Function(int value)? checkPageTitle; + + @override + String Function(int value)? checkPageDescription; + + @override + void onSaved(int value) { + this.value = value; + } + + @override + String? onValidate( + int value, String Function(String, {List? params}) translator) { + if (mandatory) {} + + return null; + } +} diff --git a/lib/src/widgets/input/input_types/input_email.dart b/lib/src/widgets/input/input_types/input_email.dart new file mode 100644 index 0000000..f635442 --- /dev/null +++ b/lib/src/widgets/input/input_types/input_email.dart @@ -0,0 +1,92 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_form/utils/translation_service.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../../flutter_form.dart'; + +/// Input for an email used in a [FlutterForm]. +/// +/// Standard controller is [FlutterFormInputEmailController]. +class FlutterFormInputEmail extends FlutterFormInputWidget { + const FlutterFormInputEmail({ + Key? key, + required FlutterFormInputController controller, + Widget? label, + }) : super( + key: key, + controller: controller, + label: label, + ); + + @override + Widget build(BuildContext context, WidgetRef ref) { + String Function(String, {List? params}) _ = + getTranslator(context, ref); + + super.registerController(context); + + return TextFormField( + initialValue: controller.value, + onSaved: (value) { + controller.onSaved(value); + }, + validator: (value) => controller.onValidate(value, _), + decoration: InputDecoration( + focusColor: Theme.of(context).primaryColor, + label: label ?? const Text("Email"), + ), + ); + } +} + +/// Controller for emails used by a [FlutterFormInputWidget] used in a [FlutterForm]. +/// +/// Mainly used by [FlutterFormInputEmail]. +class FlutterFormInputEmailController + implements FlutterFormInputController { + FlutterFormInputEmailController({ + required this.id, + this.mandatory = true, + this.value, + this.checkPageTitle, + this.checkPageDescription, + }); + + @override + String? id; + + @override + String? value; + + @override + bool mandatory; + + @override + String Function(String value)? checkPageTitle; + + @override + String Function(String value)? checkPageDescription; + + @override + void onSaved(dynamic value) { + this.value = value; + } + + @override + String? onValidate(String? value, + String Function(String, {List? params}) translator) { + if (mandatory) { + if (value == null || value.isEmpty) { + return translator('shell.form.error.empty'); + } + + if (!RegExp( + r"^[a-zA-Z0-9.a-zA-Z0-9.!#$%&'*+-/=?^_`{|}~]+@[a-zA-Z0-9]+\.[a-zA-Z]+") + .hasMatch(value)) { + return translator('shell.form.error.email.notValid'); + } + } + + return null; + } +} diff --git a/lib/src/widgets/input/input_types/input_number_picker/decimal_numberpicker.dart b/lib/src/widgets/input/input_types/input_number_picker/decimal_numberpicker.dart new file mode 100644 index 0000000..fb2819e --- /dev/null +++ b/lib/src/widgets/input/input_types/input_number_picker/decimal_numberpicker.dart @@ -0,0 +1,112 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; + +import 'numberpicker.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/widgets/input/input_types/input_number_picker/infinite_listview.dart b/lib/src/widgets/input/input_types/input_number_picker/infinite_listview.dart new file mode 100644 index 0000000..814892d --- /dev/null +++ b/lib/src/widgets/input/input_types/input_number_picker/infinite_listview.dart @@ -0,0 +1,362 @@ +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/widgets/input/input_types/input_number_picker/input_number_picker.dart b/lib/src/widgets/input/input_types/input_number_picker/input_number_picker.dart new file mode 100644 index 0000000..b9bb8c3 --- /dev/null +++ b/lib/src/widgets/input/input_types/input_number_picker/input_number_picker.dart @@ -0,0 +1,114 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_form/utils/translation_service.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../../../flutter_form.dart'; + +import 'numberpicker.dart'; + +/// Input for a number used in a [FlutterForm]. +/// +/// [minValue] sets the minimal value of the picker. +/// [maxValue] sets the maximal value of the picker. +/// +/// Standard controller is [FlutterFormInputNumberPickerController]. +class FlutterFormInputNumberPicker extends FlutterFormInputWidget { + const FlutterFormInputNumberPicker({ + Key? key, + required FlutterFormInputController controller, + Widget? label, + this.minValue = 0, + this.maxValue = 100, + }) : assert(minValue < maxValue), + super(key: key, controller: controller, label: label); + + final int minValue; + final int maxValue; + + @override + Widget build(BuildContext context, WidgetRef ref) { + String Function(String, {List? params}) _ = + getTranslator(context, ref); + + super.registerController(context); + + return NumberPickerFormField( + minValue: minValue, + maxValue: maxValue, + onSaved: (value) { + controller.onSaved(value); + }, + validator: (value) => controller.onValidate(value, _), + initialValue: controller.value ?? minValue, + ); + } +} + +/// Controller for the numberPicker used by a [FlutterFormInputWidget] used in a [FlutterForm]. +/// +/// Mainly used by [FlutterFormInputNumberPicker]. +class NumberPickerFormField extends FormField { + NumberPickerFormField({ + Key? key, + required FormFieldSetter onSaved, + required FormFieldValidator validator, + 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) { + state.didChange(value); + }, + itemHeight: 35, + itemCount: 5, + ); + }); +} + +class FlutterFormInputNumberPickerController + implements FlutterFormInputController { + FlutterFormInputNumberPickerController({ + required this.id, + this.mandatory = true, + this.value, + this.checkPageTitle, + this.checkPageDescription, + }); + + @override + String? id; + + @override + int? value; + + @override + bool mandatory; + + @override + String Function(int value)? checkPageTitle; + + @override + String Function(int value)? checkPageDescription; + + @override + void onSaved(int value) { + this.value = value; + } + + @override + String? onValidate( + int value, String Function(String, {List? params}) translator) { + if (mandatory) {} + + return null; + } +} diff --git a/lib/src/widgets/input/input_types/input_number_picker/numberpicker.dart b/lib/src/widgets/input/input_types/input_number_picker/numberpicker.dart new file mode 100644 index 0000000..a6f38c1 --- /dev/null +++ b/lib/src/widgets/input/input_types/input_number_picker/numberpicker.dart @@ -0,0 +1,305 @@ +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/widgets/input/input_types/input_password/input_password.dart b/lib/src/widgets/input/input_types/input_password/input_password.dart new file mode 100644 index 0000000..f99fc13 --- /dev/null +++ b/lib/src/widgets/input/input_types/input_password/input_password.dart @@ -0,0 +1,75 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_form/src/widgets/input/input_types/input_password/password.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../../../flutter_form.dart'; + +/// Input for a password used in a [FlutterForm]. +/// +/// Standard controller is [FlutterFormInputEmailController]. +class FlutterFormInputPassword extends FlutterFormInputWidget { + const FlutterFormInputPassword({ + Key? key, + required FlutterFormInputController controller, + Widget? label, + }) : super(key: key, controller: controller, label: label); + + @override + Widget build(BuildContext context, WidgetRef ref) { + super.registerController(context); + + return PasswordTextField( + label: label, + controller: controller, + ); + } +} + +/// Controller for passwords used by a [FlutterFormInputWidget] used in a [ShellFrom]. +/// +/// Mainly used by [FlutterFormInputPassword]. +class FlutterFormInputPasswordController + implements FlutterFormInputController { + FlutterFormInputPasswordController({ + required this.id, + this.mandatory = true, + this.value, + this.checkPageTitle, + this.checkPageDescription, + }); + + @override + String? id; + + @override + String? value; + + @override + bool mandatory; + + @override + String Function(String value)? checkPageTitle; + + @override + String Function(String value)? checkPageDescription; + + @override + void onSaved(dynamic value) { + this.value = value; + } + + @override + String? onValidate(String? value, + String Function(String, {List? params}) translator) { + if (mandatory) { + if (value == null || value.isEmpty) { + return translator('Field can not be empty'); + } + + if (value.length < 6) { + return translator('Field should be atleast 6 characters long'); + } + } + + return null; + } +} diff --git a/lib/src/widgets/input/input_types/input_password/password.dart b/lib/src/widgets/input/input_types/input_password/password.dart new file mode 100644 index 0000000..834a54d --- /dev/null +++ b/lib/src/widgets/input/input_types/input_password/password.dart @@ -0,0 +1,48 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_form/utils/translation_service.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../../../flutter_form.dart'; + +/// Generates a [TextFormField] for passwords. It requires a [FlutterFormInputController] +/// as the [controller] parameter and an optional [Widget] as [label] +class PasswordTextField extends ConsumerStatefulWidget { + final Widget? label; + final FlutterFormInputController controller; + + const PasswordTextField({ + Key? key, + required this.controller, + this.label, + }) : super(key: key); + + @override + ConsumerState createState() => _PasswordTextFieldState(); +} + +class _PasswordTextFieldState extends ConsumerState { + bool obscured = true; + + @override + Widget build(BuildContext context) { + String Function(String, {List? params}) _ = + getTranslator(context, ref); + + return TextFormField( + initialValue: widget.controller.value, + obscureText: obscured, + onSaved: (value) => widget.controller.onSaved(value), + validator: (value) => widget.controller.onValidate(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/widgets/input/input_types/input_plain_text.dart b/lib/src/widgets/input/input_types/input_plain_text.dart new file mode 100644 index 0000000..a8d44f3 --- /dev/null +++ b/lib/src/widgets/input/input_types/input_plain_text.dart @@ -0,0 +1,120 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_form/utils/translation_service.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../../flutter_form.dart'; + +/// Input for plain text input used in a [FlutterForm]. +/// +/// Standard controller is [FlutterFormInputPlainTextController]. +class FlutterFormInputPlainText extends FlutterFormInputWidget { + const FlutterFormInputPlainText({ + Key? key, + required FlutterFormInputController controller, + Widget? label, + }) : super(key: key, controller: controller, label: label); + + @override + Widget build(BuildContext context, WidgetRef ref) { + String Function(String, {List? params}) _ = + getTranslator(context, ref); + + super.registerController(context); + + return TextFormField( + initialValue: controller.value, + onSaved: (value) => controller.onSaved(value), + validator: (value) => controller.onValidate(value, _), + decoration: InputDecoration( + label: label ?? const Text("Plain text"), + ), + ); + } +} + +/// Input for an plain text with extra styling used in a [FlutterForm]. +/// +/// Standard controller is [FlutterFormInputPlainTextController]. +class FlutterFormInputPlainTextWhiteWithBorder extends FlutterFormInputWidget { + const FlutterFormInputPlainTextWhiteWithBorder({ + Key? key, + required FlutterFormInputController controller, + Widget? label, + this.hint, + }) : super(key: key, controller: controller, label: label); + + final String? hint; + + @override + Widget build(BuildContext context, WidgetRef ref) { + String Function(String, {List? params}) _ = + getTranslator(context, ref); + + super.registerController(context); + + return TextFormField( + initialValue: controller.value, + onSaved: (value) => controller.onSaved(value), + validator: (value) => controller.onValidate(value, _), + 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)), + ), + fillColor: Colors.white, + filled: true, + ), + ); + } +} + +/// Controller for plain text used by a [FlutterFormInputWidget] used in a [FlutterForm]. +/// +/// Mainly used by [FlutterFormInputPlainText]. +class FlutterFormInputPlainTextController + implements FlutterFormInputController { + FlutterFormInputPlainTextController({ + required this.id, + this.mandatory = false, + this.value, + this.checkPageTitle, + this.checkPageDescription, + }); + + @override + String? id; + + @override + String? value; + + @override + bool mandatory; + + @override + String Function(String value)? checkPageTitle; + + @override + String Function(String value)? checkPageDescription; + + @override + void onSaved(String value) { + this.value = value; + } + + @override + String? onValidate(String? value, + String Function(String, {List? params}) translator) { + if (mandatory) { + if (value == null || value.isEmpty) { + return translator('Field can not be empty'); + } + } + + return null; + } +} diff --git a/lib/src/widgets/input/input_types/input_slider/input_slider.dart b/lib/src/widgets/input/input_types/input_slider/input_slider.dart new file mode 100644 index 0000000..49a476b --- /dev/null +++ b/lib/src/widgets/input/input_types/input_slider/input_slider.dart @@ -0,0 +1,78 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_form/src/widgets/input/input_types/input_slider/slider.dart'; +import 'package:flutter_form/utils/translation_service.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../../../flutter_form.dart'; + +/// Input for a number value between two values via a slider. Used in a [FlutterForm]. +/// +/// Standard controller is [FlutterFormInputSliderController]. +class FlutterFormInputSlider extends FlutterFormInputWidget { + const FlutterFormInputSlider({ + Key? key, + required FlutterFormInputController controller, + Widget? label, + this.minValue = 0, + this.maxValue = 100, + }) : assert(minValue < maxValue), + super(key: key, controller: controller, label: label); + + final int minValue; + final int maxValue; + + @override + Widget build(BuildContext context, WidgetRef ref) { + String Function(String, {List? params}) _ = + getTranslator(context, ref); + + super.registerController(context); + + return SliderFormField( + onSaved: (value) => controller.onSaved(value), + validator: (value) => controller.onValidate(value, _), + ); + } +} + +/// Controller for slider used by a [FlutterFormInputWidget] used in a [FlutterForm]. +/// +/// Mainly used by [FlutterFormInputSlider]. +class FlutterFormInputSliderController + implements FlutterFormInputController { + FlutterFormInputSliderController({ + required this.id, + this.mandatory = true, + this.value, + this.checkPageTitle, + this.checkPageDescription, + }); + + @override + String? id; + + @override + double? value; + + @override + bool mandatory; + + @override + String Function(double value)? checkPageTitle; + + @override + String Function(double value)? checkPageDescription; + + @override + void onSaved(double value) { + this.value = value; + } + + @override + String? onValidate(double value, + String Function(String, {List? params}) translator) { + if (mandatory) {} + + return null; + } +} diff --git a/lib/src/widgets/input/input_types/input_slider/slider.dart b/lib/src/widgets/input/input_types/input_slider/slider.dart new file mode 100644 index 0000000..1ac28dd --- /dev/null +++ b/lib/src/widgets/input/input_types/input_slider/slider.dart @@ -0,0 +1,23 @@ +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, + 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) { + state.didChange(value); + }, + ); + }); +} diff --git a/lib/src/widgets/input/input_types/input_types.dart b/lib/src/widgets/input/input_types/input_types.dart new file mode 100644 index 0000000..8920d92 --- /dev/null +++ b/lib/src/widgets/input/input_types/input_types.dart @@ -0,0 +1,6 @@ +export 'input_carousel/input_carousel.dart'; +export 'input_email.dart'; +export 'input_number_picker/input_number_picker.dart'; +export 'input_password/input_password.dart'; +export 'input_plain_text.dart'; +export 'input_slider/input_slider.dart'; diff --git a/lib/utils/form.dart b/lib/utils/form.dart new file mode 100644 index 0000000..01dc684 --- /dev/null +++ b/lib/utils/form.dart @@ -0,0 +1,72 @@ +import 'package:flutter/material.dart'; + +/// The options used to set parameters to a [FlutterForm]. +/// +/// The pages determine what pages the pageview will contain via a [List] of [FlutterFormPage]s. +/// +/// Using a checkpage gives the ability for the user to check all input values before commiting by [CheckPage]. +/// If [checkPage] is null no check page will be shown. +/// +/// [nextButton] and [backButton] are both a way to give controls to user. +/// Both are just plain widgets used in a [Stack]. So the widgets can be aligned where ever. +/// The formcontroller of [FlutterForm] should be used to give control to the widgets/buttons. +/// +/// [onFinished] and [onNext] are both callbacks which give the users results. +/// [onNext] is called when the user goes to the next page. +/// [onFinished] is called when the form is finished. When checkpage is set [onFinished] is called when the checkpage is finished. +class FlutterFormOptions { + final List pages; + + final CheckPage? checkPage; + final Widget Function(int pageNumber, bool checkingPages)? nextButton; + final Widget Function(int pageNumber, bool checkingPages, int pageAmount)? + backButton; + final void Function(Map>) onFinished; + final void Function(int pageNumber, Map) onNext; + + const FlutterFormOptions({ + required this.pages, + this.checkPage, + this.nextButton, + this.backButton, + required this.onFinished, + required this.onNext, + }); +} + +/// The defines every page in a [FlutterForm]. +class FlutterFormPage { + final Widget child; + + FlutterFormPage({ + required this.child, + }); +} + +/// [CheckPage] is used to set a check page at the end of a [FlutterForm]. +/// A [CheckPage] is a page where the user can check all input values before commiting. +/// +/// [title] is the widget shown at the top of the page. +/// +/// [mainAxisAlignment] is the alignment of the check widgets. +/// +/// [inputCheckWidget] determines how every input is represented on the page. +/// [title] is the value given in the input. +/// This input can be modified by setting the [checkPageTitle] of that input controller. +/// +/// Same for the [description] but if the description is not set in the input controller no description will be given. +/// +/// [onPressed] can be set so that when the user triggers it the user will be sent back to the page including the input. +/// Here the user can modify the input and save it. Afterwards the user will be sent back to the check page. +class CheckPage { + final Widget? title; + final MainAxisAlignment mainAxisAlignment; + final Widget Function(String title, String? description, Function onPressed)? + inputCheckWidget; + + const CheckPage({ + this.title, + this.inputCheckWidget, + this.mainAxisAlignment = MainAxisAlignment.start, + }); +} diff --git a/lib/utils/providers.dart b/lib/utils/providers.dart new file mode 100644 index 0000000..ed46630 --- /dev/null +++ b/lib/utils/providers.dart @@ -0,0 +1,6 @@ +import 'package:flutter_form/utils/translation_service.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// Provides the [ShellTranslationService] +final translationServiceProvider = + Provider((ref) => ShellTranslationService()); diff --git a/lib/utils/translation_service.dart b/lib/utils/translation_service.dart new file mode 100644 index 0000000..2432901 --- /dev/null +++ b/lib/utils/translation_service.dart @@ -0,0 +1,51 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_form/utils/providers.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +abstract class TranslationService { + TranslationService._(); + + String translate( + BuildContext context, + String key, { + List? params, + }); + + String number(double value); +} + +typedef Translator = String Function( + String, { + List? params, +}); + +class ShellTranslationService implements TranslationService { + @override + String number(double value) { + return value.toStringAsFixed(2); + } + + @override + String translate(BuildContext context, String key, {List? params}) { + return key; + } +} + +Translator getTranslator(BuildContext context, WidgetRef ref) { + try { + var translator = ref.read(translationServiceProvider).translate; + return ( + String key, { + List? params, + }) { + return translator(context, key, params: params); + }; + } catch (e) { + return ( + String key, { + List? params, + }) { + return key; + }; + } +} diff --git a/pubspec.yaml b/pubspec.yaml index ac55719..6cf096c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -3,6 +3,8 @@ description: A new Flutter package project. version: 0.0.1 homepage: +publish_to: none + environment: sdk: '>=2.18.0 <3.0.0' flutter: ">=1.17.0" @@ -10,16 +12,19 @@ environment: dependencies: flutter: sdk: flutter + flutter_localizations: + sdk: flutter + flutter_riverpod: ^1.0.4 + localization: ^2.1.0 + + sliding_up_panel: ^2.0.0+1 + uuid: ^3.0.6 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: diff --git a/test/flutter_form_test.dart b/test/flutter_form_test.dart index bfb3cac..a665afd 100644 --- a/test/flutter_form_test.dart +++ b/test/flutter_form_test.dart @@ -1,12 +1,278 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_form/flutter_form.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:flutter_form/flutter_form.dart'; - void main() { - test('adds one to input values', () { - final calculator = Calculator(); - expect(calculator.addOne(2), 3); - expect(calculator.addOne(-7), -6); - expect(calculator.addOne(0), 1); + testWidgets('Normal walk through without check page', (tester) async { + FlutterFormController formController = FlutterFormController(); + + var testField1Controller = FlutterFormInputPlainTextController( + id: 'Field1', + ); + + var testField2Controller = FlutterFormInputPlainTextController( + id: 'Field2', + ); + + int? onNextPageNumber; + Map? onNextResults; + + Map>? onFinishResults; + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: FlutterForm( + options: FlutterFormOptions( + nextButton: (pageNumber, checkingPages) { + return Align( + alignment: Alignment.bottomCenter, + child: ElevatedButton( + onPressed: () async { + await formController.autoNextStep(); + }, + child: Text(pageNumber == 0 + ? 'next1' + : pageNumber == 1 + ? 'next2' + : 'finish'), + ), + ); + }, + onFinished: (Map> results) { + onFinishResults = results; + }, + onNext: (int pageNumber, Map results) { + onNextPageNumber = pageNumber; + onNextResults = results; + }, + pages: [ + FlutterFormPage( + child: Center( + child: FlutterFormInputPlainText( + label: const Text('Field1Label'), + controller: testField1Controller, + ), + ), + ), + FlutterFormPage( + child: Center( + child: FlutterFormInputPlainText( + label: const Text('Field2Label'), + controller: testField2Controller, + ), + ), + ), + ], + ), + formController: formController, + ), + ), + ), + ); + + await tester.enterText( + find.widgetWithText(TextFormField, 'Field1Label'), 'Field1Input'); + await tester.tap(find.widgetWithText(ElevatedButton, 'next1')); + await tester.pumpAndSettle(); + + expect(0, onNextPageNumber); + expect({'Field1': 'Field1Input'}, onNextResults); + + await tester.enterText( + find.widgetWithText(TextFormField, 'Field2Label'), 'Field2Input'); + await tester.tap(find.widgetWithText(ElevatedButton, 'next2')); + await tester.pumpAndSettle(); + + expect(1, onNextPageNumber); + expect({'Field2': 'Field2Input'}, onNextResults); + + expect({ + 0: {'Field1': 'Field1Input'}, + 1: {'Field2': 'Field2Input'} + }, onFinishResults); + }); + + testWidgets('Normal walk through with check page', (tester) async { + FlutterFormController formController = FlutterFormController(); + + var testField1Controller = FlutterFormInputPlainTextController( + id: 'Field1', + ); + + var testField2Controller = FlutterFormInputPlainTextController( + id: 'Field2', + ); + + int? onNextPageNumber; + Map? onNextResults; + + Map>? onFinishResults; + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: FlutterForm( + options: FlutterFormOptions( + checkPage: const CheckPage(), + nextButton: (pageNumber, checkingPages) { + return Align( + alignment: Alignment.bottomCenter, + child: ElevatedButton( + onPressed: () async { + await formController.autoNextStep(); + }, + child: Text(pageNumber == 0 + ? 'next1' + : pageNumber == 1 + ? 'next2' + : 'finish'), + ), + ); + }, + onFinished: (Map> results) { + onFinishResults = results; + }, + onNext: (int pageNumber, Map results) { + onNextPageNumber = pageNumber; + onNextResults = results; + }, + pages: [ + FlutterFormPage( + child: Center( + child: FlutterFormInputPlainText( + label: const Text('Field1Label'), + controller: testField1Controller, + ), + ), + ), + FlutterFormPage( + child: Center( + child: FlutterFormInputPlainText( + label: const Text('Field2Label'), + controller: testField2Controller, + ), + ), + ), + ], + ), + formController: formController, + ), + ), + ), + ); + + await tester.enterText( + find.widgetWithText(TextFormField, 'Field1Label'), 'Field1Input'); + await tester.tap(find.widgetWithText(ElevatedButton, 'next1')); + await tester.pumpAndSettle(); + + expect(0, onNextPageNumber); + expect({'Field1': 'Field1Input'}, onNextResults); + + await tester.enterText( + find.widgetWithText(TextFormField, 'Field2Label'), 'Field2Input'); + await tester.tap(find.widgetWithText(ElevatedButton, 'next2')); + await tester.pumpAndSettle(); + + expect(1, onNextPageNumber); + expect({'Field2': 'Field2Input'}, onNextResults); + + await tester.tap(find.text('Field1Input')); + await tester.pumpAndSettle(); + + await tester.enterText( + find.widgetWithText(TextFormField, 'Field1Label'), 'Field1Input2'); + await tester.tap(find.widgetWithText(ElevatedButton, 'next1')); + await tester.pumpAndSettle(); + + expect(0, onNextPageNumber); + expect({'Field1': 'Field1Input2'}, onNextResults); + + await tester.tap(find.widgetWithText(ElevatedButton, "finish")); + await tester.pumpAndSettle(); + + expect({ + 0: {'Field1': 'Field1Input2'}, + 1: {'Field2': 'Field2Input'} + }, onFinishResults); + }); + + testWidgets('Wrong input with mandatory validator', (tester) async { + FlutterFormController formController = FlutterFormController(); + + var testField1Controller = FlutterFormInputPlainTextController( + id: 'Field1', + mandatory: true, + ); + + int? onNextPageNumber; + Map? onNextResults; + + Map>? onFinishResults; + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: FlutterForm( + options: FlutterFormOptions( + nextButton: (pageNumber, checkingPages) { + return Align( + alignment: Alignment.bottomCenter, + child: ElevatedButton( + onPressed: () async { + await formController.autoNextStep(); + }, + child: const Text('finish'), + ), + ); + }, + onFinished: (Map> results) { + // print('finished results: $results'); + onFinishResults = results; + }, + onNext: (int pageNumber, Map results) { + // print('nextResults: $pageNumber: $results'); + onNextPageNumber = pageNumber; + onNextResults = results; + }, + pages: [ + FlutterFormPage( + child: Center( + child: FlutterFormInputPlainText( + label: const Text('Field1Label'), + controller: testField1Controller, + ), + ), + ), + ], + ), + formController: formController, + ), + ), + ), + ); + + await tester.tap(find.widgetWithText(ElevatedButton, 'finish')); + await tester.pumpAndSettle(); + + expect(null, onNextPageNumber); + expect(null, onNextResults); + + final errorMessageFinder = find.text('Field can not be empty'); + + expect(errorMessageFinder, findsOneWidget); + + await tester.enterText( + find.widgetWithText(TextFormField, 'Field1Label'), 'Field1Input'); + await tester.tap(find.widgetWithText(ElevatedButton, 'finish')); + await tester.pumpAndSettle(); + + expect(0, onNextPageNumber); + expect({'Field1': 'Field1Input'}, onNextResults); + + expect({ + 0: {'Field1': 'Field1Input'}, + }, onFinishResults); }); }