mirror of
https://github.com/Iconica-Development/flutter_form_wizard.git
synced 2025-05-19 19:03:47 +02:00
commit
839ea1a41f
45 changed files with 4075 additions and 153 deletions
|
@ -1,3 +1,3 @@
|
|||
## 0.0.1
|
||||
## 0.0.1 - September 29th 2022
|
||||
|
||||
* TODO: Describe initial release.
|
||||
- Initial release
|
||||
|
|
77
README.md
77
README.md
|
@ -1,39 +1,60 @@
|
|||
<!--
|
||||
This README describes the package. If you publish this package to pub.dev,
|
||||
this README's contents appear on the landing page for your package.
|
||||
# Flutter Form
|
||||
|
||||
For information about how to write a good package README, see the guide for
|
||||
[writing package pages](https://dart.dev/guides/libraries/writing-package-pages).
|
||||
|
||||
For general information about developing packages, see the Dart guide for
|
||||
[creating packages](https://dart.dev/guides/libraries/create-library-packages)
|
||||
and the Flutter guide for
|
||||
[developing packages and plugins](https://flutter.dev/developing-packages).
|
||||
-->
|
||||
|
||||
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 <support@iconica.nl>
|
||||
|
|
BIN
example/assets/images/BMW.png
Normal file
BIN
example/assets/images/BMW.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 34 KiB |
BIN
example/assets/images/Mazda.png
Normal file
BIN
example/assets/images/Mazda.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 802 KiB |
BIN
example/assets/images/Mercedes.png
Normal file
BIN
example/assets/images/Mercedes.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 103 KiB |
38
example/lib/example_pages/age_page.dart
Normal file
38
example/lib/example_pages/age_page.dart
Normal file
|
@ -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<AgePage> createState() => _AgePageState();
|
||||
}
|
||||
|
||||
class _AgePageState extends State<AgePage> {
|
||||
@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,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
80
example/lib/example_pages/carousel_page.dart
Normal file
80
example/lib/example_pages/carousel_page.dart
Normal file
|
@ -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<Map<String, dynamic>> cars;
|
||||
|
||||
@override
|
||||
State<CarouselPage> createState() => _CarouselPageState();
|
||||
}
|
||||
|
||||
class _CarouselPageState extends State<CarouselPage> {
|
||||
@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<Widget> 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();
|
||||
}
|
||||
}
|
92
example/lib/example_pages/check_page.dart
Normal file
92
example/lib/example_pages/check_page.dart
Normal file
|
@ -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),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
52
example/lib/example_pages/name_page.dart
Normal file
52
example/lib/example_pages/name_page.dart
Normal file
|
@ -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<NamePage> createState() => _NamePageState();
|
||||
}
|
||||
|
||||
class _NamePageState extends State<NamePage> {
|
||||
@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,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
28
example/lib/example_pages/thanks_page.dart
Normal file
28
example/lib/example_pages/thanks_page.dart
Normal file
|
@ -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"))
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
201
example/lib/form_example.dart
Normal file
201
example/lib/form_example.dart
Normal file
|
@ -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<ConsumerStatefulWidget> createState() => _FormExampleState();
|
||||
}
|
||||
|
||||
class _FormExampleState extends ConsumerState<FormExample> {
|
||||
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<Map<String, dynamic>> 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<int, Map<String, dynamic>> results) {
|
||||
debugPrint("Final full results: $results");
|
||||
Navigator.of(context).pushNamed('/thanks');
|
||||
},
|
||||
onNext: (int pageNumber, Map<String, dynamic> 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),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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<FormsHomePage> {
|
|||
),
|
||||
body: Center(
|
||||
child: ElevatedButton(
|
||||
onPressed: (() => createForm()),
|
||||
onPressed: (() => Navigator.of(context).pushNamed('/form')),
|
||||
child: const Text('Create form'),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void createForm() {}
|
||||
}
|
||||
|
|
12
example/lib/route.dart
Normal file
12
example/lib/route.dart
Normal file
|
@ -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<String, WidgetBuilder> getRoutes() {
|
||||
return {
|
||||
'/': (context) => const FormsExample(),
|
||||
'/form': (context) => const FormExample(),
|
||||
'/thanks': (context) => const ThanksPage(),
|
||||
};
|
||||
}
|
66
example/lib/template_page.dart
Normal file
66
example/lib/template_page.dart
Normal file
|
@ -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<Widget> 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,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
|
|
|
@ -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/
|
|
@ -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);
|
||||
});
|
||||
}
|
|
@ -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';
|
||||
|
|
555
lib/src/form.dart
Normal file
555
lib/src/form.dart
Normal file
|
@ -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<int, Map<String, dynamic>> results) {
|
||||
/// // print(results);
|
||||
/// },
|
||||
/// onNext: (int pageNumber, Map<String, dynamic> 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<FlutterForm> createState() => _FlutterFormState();
|
||||
}
|
||||
|
||||
class _FlutterFormState extends ConsumerState<FlutterForm> {
|
||||
late FlutterFormController _formController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
_formController = widget.formController;
|
||||
|
||||
_formController.setFlutterFormOptions(widget.options);
|
||||
|
||||
List<GlobalKey<FormState>> keys = [];
|
||||
|
||||
for (FlutterFormPage _ in widget.options.pages) {
|
||||
keys.add(GlobalKey<FormState>());
|
||||
}
|
||||
|
||||
_formController.setKeys(keys);
|
||||
|
||||
_formController.addListener(() {
|
||||
setState(() {});
|
||||
});
|
||||
|
||||
List<FlutterFormPageController> 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<Widget> getResultWidgets() {
|
||||
List<Widget> 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<GlobalKey<FormState>> _keys;
|
||||
|
||||
bool _checkingPages = false;
|
||||
|
||||
final PageController _pageController = PageController();
|
||||
|
||||
late List<FlutterFormPageController> _formPageControllers;
|
||||
|
||||
List<FlutterFormPageController> getFormPageControllers() {
|
||||
return _formPageControllers;
|
||||
}
|
||||
|
||||
setFormPageControllers(List<FlutterFormPageController> controllers) {
|
||||
_formPageControllers = controllers;
|
||||
}
|
||||
|
||||
disableCheckingPages() {
|
||||
_checkingPages = false;
|
||||
|
||||
for (var controller in _formPageControllers) {
|
||||
controller.clearControllers();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> 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<void> previousStep() async {
|
||||
_currentStep -= 1;
|
||||
|
||||
_checkingPages = false;
|
||||
|
||||
notifyListeners();
|
||||
|
||||
await _pageController.animateToPage(
|
||||
_currentStep,
|
||||
duration: const Duration(milliseconds: 250),
|
||||
curve: Curves.ease,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> 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<String, dynamic> getCurrentStepResults() {
|
||||
return _formPageControllers[_currentStep].getAllValues();
|
||||
}
|
||||
|
||||
Future<void> 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<int, Map<String, dynamic>> getAllResults() {
|
||||
Map<int, Map<String, dynamic>> 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<GlobalKey<FormState>> keys) {
|
||||
_keys = keys;
|
||||
}
|
||||
|
||||
List<GlobalKey<FormState>> getKeys() {
|
||||
return _keys;
|
||||
}
|
||||
|
||||
int getCurrentStep() {
|
||||
return _currentStep;
|
||||
}
|
||||
|
||||
bool getCheckpages() {
|
||||
return _checkingPages;
|
||||
}
|
||||
|
||||
PageController getPageController() {
|
||||
return _pageController;
|
||||
}
|
||||
}
|
36
lib/src/utils/form_page_controller.dart
Normal file
36
lib/src/utils/form_page_controller.dart
Normal file
|
@ -0,0 +1,36 @@
|
|||
import 'package:flutter_form/flutter_form.dart';
|
||||
|
||||
class FlutterFormPageController {
|
||||
List<FlutterFormInputController> _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<String, dynamic> getAllValues() {
|
||||
Map<String, dynamic> values = {};
|
||||
|
||||
for (FlutterFormInputController controller in _controllers) {
|
||||
if (controller.value != null) {
|
||||
values.addAll({controller.id!: controller.value});
|
||||
}
|
||||
}
|
||||
|
||||
return values;
|
||||
}
|
||||
}
|
23
lib/src/utils/formstate.dart
Normal file
23
lib/src/utils/formstate.dart
Normal file
|
@ -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<FormState>();
|
||||
assert(result != null, 'No FormStat found in context');
|
||||
return result!;
|
||||
}
|
||||
|
||||
@override
|
||||
bool updateShouldNotify(FormState oldWidget) =>
|
||||
formController != oldWidget.formController;
|
||||
}
|
93
lib/src/widgets/input/abstractions.dart
Normal file
93
lib/src/widgets/input/abstractions.dart
Normal file
|
@ -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<T> {
|
||||
/// 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<String>? params}) translator);
|
||||
}
|
|
@ -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<void> get onReady;
|
||||
|
||||
Future<void> nextPage({Duration? duration, Curve? curve});
|
||||
|
||||
Future<void> previousPage({Duration? duration, Curve? curve});
|
||||
|
||||
void jumpToPage(int page);
|
||||
|
||||
Future<void> animateToPage(int page, {Duration? duration, Curve? curve});
|
||||
|
||||
void startAutoPlay();
|
||||
|
||||
void stopAutoPlay();
|
||||
|
||||
factory CarouselController() => CarouselControllerImpl();
|
||||
}
|
||||
|
||||
class CarouselControllerImpl implements CarouselController {
|
||||
final Completer<void> _readyCompleter = Completer<void>();
|
||||
|
||||
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<void> 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<void> 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<void> 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<void> 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();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,34 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'carousel_slider.dart';
|
||||
|
||||
class CarouselFormField extends FormField<int> {
|
||||
CarouselFormField({
|
||||
Key? key,
|
||||
required FormFieldSetter<int> onSaved,
|
||||
required FormFieldValidator<int> validator,
|
||||
int initialValue = 0,
|
||||
bool autovalidate = false,
|
||||
required List<Widget> items,
|
||||
}) : super(
|
||||
key: key,
|
||||
onSaved: onSaved,
|
||||
validator: validator,
|
||||
initialValue: initialValue,
|
||||
builder: (FormFieldState<int> 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(),
|
||||
);
|
||||
});
|
||||
}
|
|
@ -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<double?>? 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<double?>? 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,
|
||||
);
|
||||
}
|
|
@ -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<Widget>? 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<CarouselSlider>
|
||||
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 {}
|
|
@ -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);
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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<Widget> items;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
String Function(String, {List<String>? 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<int> {
|
||||
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<String>? params}) translator) {
|
||||
if (mandatory) {}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
92
lib/src/widgets/input/input_types/input_email.dart
Normal file
92
lib/src/widgets/input/input_types/input_email.dart
Normal file
|
@ -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<String>? 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<String> {
|
||||
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<String>? 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;
|
||||
}
|
||||
}
|
|
@ -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<double> 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);
|
||||
}
|
||||
}
|
|
@ -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<InfiniteListView> {
|
||||
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<Widget> slivers = _buildSlivers(context, negative: false);
|
||||
final List<Widget> 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: <Widget>[
|
||||
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<Widget> _buildSlivers(BuildContext context, {bool negative = false}) {
|
||||
final itemExtent = widget.itemExtent;
|
||||
final padding = widget.padding ?? EdgeInsets.zero;
|
||||
return <Widget>[
|
||||
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<Axis>('scrollDirection', widget.scrollDirection));
|
||||
properties.add(FlagProperty('reverse',
|
||||
value: widget.reverse, ifTrue: 'reversed', showName: true));
|
||||
properties.add(DiagnosticsProperty<ScrollController>(
|
||||
'controller', widget.controller,
|
||||
showName: false, defaultValue: null));
|
||||
properties.add(DiagnosticsProperty<ScrollPhysics>('physics', widget.physics,
|
||||
showName: false, defaultValue: null));
|
||||
properties.add(DiagnosticsProperty<EdgeInsetsGeometry>(
|
||||
'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;
|
||||
}
|
|
@ -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<String>? 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<int> {
|
||||
NumberPickerFormField({
|
||||
Key? key,
|
||||
required FormFieldSetter<int> onSaved,
|
||||
required FormFieldValidator<int> validator,
|
||||
int initialValue = 0,
|
||||
bool autovalidate = false,
|
||||
int minValue = 0,
|
||||
int maxValue = 100,
|
||||
}) : super(
|
||||
key: key,
|
||||
onSaved: onSaved,
|
||||
validator: validator,
|
||||
initialValue: initialValue,
|
||||
builder: (FormFieldState<int> state) {
|
||||
return NumberPicker(
|
||||
minValue: minValue,
|
||||
maxValue: maxValue,
|
||||
value: initialValue,
|
||||
onChanged: (int value) {
|
||||
state.didChange(value);
|
||||
},
|
||||
itemHeight: 35,
|
||||
itemCount: 5,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
class FlutterFormInputNumberPickerController
|
||||
implements FlutterFormInputController<int> {
|
||||
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<String>? params}) translator) {
|
||||
if (mandatory) {}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
|
@ -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<int> 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<NumberPicker> {
|
||||
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<ScrollEndNotification>(
|
||||
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;
|
||||
}
|
|
@ -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<String> {
|
||||
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<String>? 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;
|
||||
}
|
||||
}
|
|
@ -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<PasswordTextField> createState() => _PasswordTextFieldState();
|
||||
}
|
||||
|
||||
class _PasswordTextFieldState extends ConsumerState<PasswordTextField> {
|
||||
bool obscured = true;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
String Function(String, {List<String>? 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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
120
lib/src/widgets/input/input_types/input_plain_text.dart
Normal file
120
lib/src/widgets/input/input_types/input_plain_text.dart
Normal file
|
@ -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<String>? 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<String>? 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<String> {
|
||||
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<String>? params}) translator) {
|
||||
if (mandatory) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return translator('Field can not be empty');
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
|
@ -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<String>? 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<double> {
|
||||
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<String>? params}) translator) {
|
||||
if (mandatory) {}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
23
lib/src/widgets/input/input_types/input_slider/slider.dart
Normal file
23
lib/src/widgets/input/input_types/input_slider/slider.dart
Normal file
|
@ -0,0 +1,23 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Creates a slider with the given input parameters
|
||||
class SliderFormField extends FormField<double> {
|
||||
SliderFormField({
|
||||
Key? key,
|
||||
required FormFieldSetter<double> onSaved,
|
||||
required FormFieldValidator<double> validator,
|
||||
double initialValue = 0.5,
|
||||
}) : super(
|
||||
key: key,
|
||||
onSaved: onSaved,
|
||||
validator: validator,
|
||||
initialValue: initialValue,
|
||||
builder: (FormFieldState<double> state) {
|
||||
return Slider(
|
||||
value: state.value ?? initialValue,
|
||||
onChanged: (double value) {
|
||||
state.didChange(value);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
6
lib/src/widgets/input/input_types/input_types.dart
Normal file
6
lib/src/widgets/input/input_types/input_types.dart
Normal file
|
@ -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';
|
72
lib/utils/form.dart
Normal file
72
lib/utils/form.dart
Normal file
|
@ -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<FlutterFormPage> 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<int, Map<String, dynamic>>) onFinished;
|
||||
final void Function(int pageNumber, Map<String, dynamic>) 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,
|
||||
});
|
||||
}
|
6
lib/utils/providers.dart
Normal file
6
lib/utils/providers.dart
Normal file
|
@ -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<TranslationService>((ref) => ShellTranslationService());
|
51
lib/utils/translation_service.dart
Normal file
51
lib/utils/translation_service.dart
Normal file
|
@ -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<String>? params,
|
||||
});
|
||||
|
||||
String number(double value);
|
||||
}
|
||||
|
||||
typedef Translator = String Function(
|
||||
String, {
|
||||
List<String>? params,
|
||||
});
|
||||
|
||||
class ShellTranslationService implements TranslationService {
|
||||
@override
|
||||
String number(double value) {
|
||||
return value.toStringAsFixed(2);
|
||||
}
|
||||
|
||||
@override
|
||||
String translate(BuildContext context, String key, {List<String>? params}) {
|
||||
return key;
|
||||
}
|
||||
}
|
||||
|
||||
Translator getTranslator(BuildContext context, WidgetRef ref) {
|
||||
try {
|
||||
var translator = ref.read(translationServiceProvider).translate;
|
||||
return (
|
||||
String key, {
|
||||
List<String>? params,
|
||||
}) {
|
||||
return translator(context, key, params: params);
|
||||
};
|
||||
} catch (e) {
|
||||
return (
|
||||
String key, {
|
||||
List<String>? params,
|
||||
}) {
|
||||
return key;
|
||||
};
|
||||
}
|
||||
}
|
13
pubspec.yaml
13
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:
|
||||
|
|
|
@ -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<String, dynamic>? onNextResults;
|
||||
|
||||
Map<int, Map<String, dynamic>>? 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<int, Map<String, dynamic>> results) {
|
||||
onFinishResults = results;
|
||||
},
|
||||
onNext: (int pageNumber, Map<String, dynamic> 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<String, dynamic>? onNextResults;
|
||||
|
||||
Map<int, Map<String, dynamic>>? 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<int, Map<String, dynamic>> results) {
|
||||
onFinishResults = results;
|
||||
},
|
||||
onNext: (int pageNumber, Map<String, dynamic> 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<String, dynamic>? onNextResults;
|
||||
|
||||
Map<int, Map<String, dynamic>>? 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<int, Map<String, dynamic>> results) {
|
||||
// print('finished results: $results');
|
||||
onFinishResults = results;
|
||||
},
|
||||
onNext: (int pageNumber, Map<String, dynamic> 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);
|
||||
});
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue