Add comprehensive Flutter cross-platform development guide covering: - Widget patterns and const optimization - Riverpod/Bloc state management - GoRouter navigation - Performance optimization - Testing strategies - Platform-specific implementations Made-with: Cursor
657 lines
17 KiB
Markdown
657 lines
17 KiB
Markdown
# Forms
|
|
|
|
Form validation, FormField patterns, input formatting, and reusable form components for Flutter.
|
|
|
|
## Basic Form Setup
|
|
|
|
```dart
|
|
class LoginForm extends StatefulWidget {
|
|
const LoginForm({super.key});
|
|
|
|
@override
|
|
State<LoginForm> createState() => _LoginFormState();
|
|
}
|
|
|
|
class _LoginFormState extends State<LoginForm> {
|
|
final _formKey = GlobalKey<FormState>();
|
|
final _emailController = TextEditingController();
|
|
final _passwordController = TextEditingController();
|
|
bool _isLoading = false;
|
|
|
|
@override
|
|
void dispose() {
|
|
_emailController.dispose();
|
|
_passwordController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> _submit() async {
|
|
if (!_formKey.currentState!.validate()) return;
|
|
|
|
setState(() => _isLoading = true);
|
|
|
|
try {
|
|
await authService.login(
|
|
email: _emailController.text.trim(),
|
|
password: _passwordController.text,
|
|
);
|
|
} finally {
|
|
if (mounted) setState(() => _isLoading = false);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Form(
|
|
key: _formKey,
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
TextFormField(
|
|
controller: _emailController,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Email',
|
|
prefixIcon: Icon(Icons.email_outlined),
|
|
),
|
|
keyboardType: TextInputType.emailAddress,
|
|
textInputAction: TextInputAction.next,
|
|
autocorrect: false,
|
|
validator: Validators.email,
|
|
),
|
|
const SizedBox(height: 16),
|
|
TextFormField(
|
|
controller: _passwordController,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Password',
|
|
prefixIcon: Icon(Icons.lock_outlined),
|
|
),
|
|
obscureText: true,
|
|
textInputAction: TextInputAction.done,
|
|
onFieldSubmitted: (_) => _submit(),
|
|
validator: Validators.password,
|
|
),
|
|
const SizedBox(height: 24),
|
|
ElevatedButton(
|
|
onPressed: _isLoading ? null : _submit,
|
|
child: _isLoading
|
|
? const SizedBox(
|
|
height: 20,
|
|
width: 20,
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
)
|
|
: const Text('Login'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
```
|
|
|
|
## Validators
|
|
|
|
```dart
|
|
class Validators {
|
|
static String? required(String? value) {
|
|
if (value == null || value.trim().isEmpty) {
|
|
return 'This field is required';
|
|
}
|
|
return null;
|
|
}
|
|
|
|
static String? email(String? value) {
|
|
if (value == null || value.trim().isEmpty) {
|
|
return 'Email is required';
|
|
}
|
|
final regex = RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$');
|
|
if (!regex.hasMatch(value.trim())) {
|
|
return 'Enter a valid email address';
|
|
}
|
|
return null;
|
|
}
|
|
|
|
static String? password(String? value) {
|
|
if (value == null || value.isEmpty) {
|
|
return 'Password is required';
|
|
}
|
|
if (value.length < 8) {
|
|
return 'Password must be at least 8 characters';
|
|
}
|
|
return null;
|
|
}
|
|
|
|
static String? strongPassword(String? value) {
|
|
if (value == null || value.isEmpty) {
|
|
return 'Password is required';
|
|
}
|
|
if (value.length < 8) {
|
|
return 'Password must be at least 8 characters';
|
|
}
|
|
if (!RegExp(r'[A-Z]').hasMatch(value)) {
|
|
return 'Password must contain an uppercase letter';
|
|
}
|
|
if (!RegExp(r'[a-z]').hasMatch(value)) {
|
|
return 'Password must contain a lowercase letter';
|
|
}
|
|
if (!RegExp(r'[0-9]').hasMatch(value)) {
|
|
return 'Password must contain a number';
|
|
}
|
|
return null;
|
|
}
|
|
|
|
static String? phone(String? value) {
|
|
if (value == null || value.trim().isEmpty) {
|
|
return 'Phone number is required';
|
|
}
|
|
final digits = value.replaceAll(RegExp(r'\D'), '');
|
|
if (digits.length < 10 || digits.length > 15) {
|
|
return 'Enter a valid phone number';
|
|
}
|
|
return null;
|
|
}
|
|
|
|
static String? minLength(int min) {
|
|
return (String? value) {
|
|
if (value == null || value.length < min) {
|
|
return 'Must be at least $min characters';
|
|
}
|
|
return null;
|
|
};
|
|
}
|
|
|
|
static String? maxLength(int max) {
|
|
return (String? value) {
|
|
if (value != null && value.length > max) {
|
|
return 'Must be at most $max characters';
|
|
}
|
|
return null;
|
|
};
|
|
}
|
|
|
|
static String? Function(String?) combine(List<String? Function(String?)> validators) {
|
|
return (String? value) {
|
|
for (final validator in validators) {
|
|
final error = validator(value);
|
|
if (error != null) return error;
|
|
}
|
|
return null;
|
|
};
|
|
}
|
|
|
|
static String? match(String pattern, String message) {
|
|
return (String? value) {
|
|
if (value != null && !RegExp(pattern).hasMatch(value)) {
|
|
return message;
|
|
}
|
|
return null;
|
|
};
|
|
}
|
|
|
|
static String? confirmPassword(TextEditingController passwordController) {
|
|
return (String? value) {
|
|
if (value != passwordController.text) {
|
|
return 'Passwords do not match';
|
|
}
|
|
return null;
|
|
};
|
|
}
|
|
}
|
|
```
|
|
|
|
## Input Formatters
|
|
|
|
```dart
|
|
import 'package:flutter/services.dart';
|
|
|
|
class PhoneInputFormatter extends TextInputFormatter {
|
|
@override
|
|
TextEditingValue formatEditUpdate(
|
|
TextEditingValue oldValue,
|
|
TextEditingValue newValue,
|
|
) {
|
|
final digits = newValue.text.replaceAll(RegExp(r'\D'), '');
|
|
final buffer = StringBuffer();
|
|
|
|
for (int i = 0; i < digits.length && i < 10; i++) {
|
|
if (i == 3 || i == 6) buffer.write('-');
|
|
buffer.write(digits[i]);
|
|
}
|
|
|
|
return TextEditingValue(
|
|
text: buffer.toString(),
|
|
selection: TextSelection.collapsed(offset: buffer.length),
|
|
);
|
|
}
|
|
}
|
|
|
|
class CreditCardFormatter extends TextInputFormatter {
|
|
@override
|
|
TextEditingValue formatEditUpdate(
|
|
TextEditingValue oldValue,
|
|
TextEditingValue newValue,
|
|
) {
|
|
final digits = newValue.text.replaceAll(RegExp(r'\D'), '');
|
|
final buffer = StringBuffer();
|
|
|
|
for (int i = 0; i < digits.length && i < 16; i++) {
|
|
if (i > 0 && i % 4 == 0) buffer.write(' ');
|
|
buffer.write(digits[i]);
|
|
}
|
|
|
|
return TextEditingValue(
|
|
text: buffer.toString(),
|
|
selection: TextSelection.collapsed(offset: buffer.length),
|
|
);
|
|
}
|
|
}
|
|
|
|
class CurrencyInputFormatter extends TextInputFormatter {
|
|
final int decimalPlaces;
|
|
|
|
CurrencyInputFormatter({this.decimalPlaces = 2});
|
|
|
|
@override
|
|
TextEditingValue formatEditUpdate(
|
|
TextEditingValue oldValue,
|
|
TextEditingValue newValue,
|
|
) {
|
|
if (newValue.text.isEmpty) return newValue;
|
|
|
|
final digits = newValue.text.replaceAll(RegExp(r'[^\d]'), '');
|
|
if (digits.isEmpty) return const TextEditingValue(text: '');
|
|
|
|
final value = int.parse(digits) / 100;
|
|
final formatted = value.toStringAsFixed(decimalPlaces);
|
|
|
|
return TextEditingValue(
|
|
text: formatted,
|
|
selection: TextSelection.collapsed(offset: formatted.length),
|
|
);
|
|
}
|
|
}
|
|
|
|
class UpperCaseFormatter extends TextInputFormatter {
|
|
@override
|
|
TextEditingValue formatEditUpdate(
|
|
TextEditingValue oldValue,
|
|
TextEditingValue newValue,
|
|
) {
|
|
return newValue.copyWith(text: newValue.text.toUpperCase());
|
|
}
|
|
}
|
|
```
|
|
|
|
### Using Formatters
|
|
|
|
```dart
|
|
TextFormField(
|
|
decoration: const InputDecoration(labelText: 'Phone'),
|
|
keyboardType: TextInputType.phone,
|
|
inputFormatters: [
|
|
FilteringTextInputFormatter.digitsOnly,
|
|
PhoneInputFormatter(),
|
|
],
|
|
)
|
|
|
|
TextFormField(
|
|
decoration: const InputDecoration(labelText: 'Amount'),
|
|
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
|
inputFormatters: [
|
|
FilteringTextInputFormatter.allow(RegExp(r'[\d.]')),
|
|
CurrencyInputFormatter(),
|
|
],
|
|
)
|
|
```
|
|
|
|
## Custom FormFields
|
|
|
|
### Dropdown FormField
|
|
|
|
```dart
|
|
class DropdownFormField<T> extends FormField<T> {
|
|
DropdownFormField({
|
|
super.key,
|
|
required List<DropdownMenuItem<T>> items,
|
|
super.initialValue,
|
|
super.validator,
|
|
super.onSaved,
|
|
String? labelText,
|
|
String? hintText,
|
|
ValueChanged<T?>? onChanged,
|
|
}) : super(
|
|
builder: (state) {
|
|
return InputDecorator(
|
|
decoration: InputDecoration(
|
|
labelText: labelText,
|
|
errorText: state.errorText,
|
|
),
|
|
child: DropdownButtonHideUnderline(
|
|
child: DropdownButton<T>(
|
|
value: state.value,
|
|
hint: hintText != null ? Text(hintText) : null,
|
|
isExpanded: true,
|
|
items: items,
|
|
onChanged: (value) {
|
|
state.didChange(value);
|
|
onChanged?.call(value);
|
|
},
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
```
|
|
|
|
### Checkbox FormField
|
|
|
|
```dart
|
|
class CheckboxFormField extends FormField<bool> {
|
|
CheckboxFormField({
|
|
super.key,
|
|
required Widget label,
|
|
super.initialValue = false,
|
|
super.validator,
|
|
super.onSaved,
|
|
}) : super(
|
|
builder: (state) {
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Checkbox(
|
|
value: state.value ?? false,
|
|
onChanged: state.didChange,
|
|
),
|
|
Expanded(child: GestureDetector(
|
|
onTap: () => state.didChange(!(state.value ?? false)),
|
|
child: label,
|
|
)),
|
|
],
|
|
),
|
|
if (state.hasError)
|
|
Padding(
|
|
padding: const EdgeInsets.only(left: 12, top: 4),
|
|
child: Text(
|
|
state.errorText!,
|
|
style: TextStyle(
|
|
color: Theme.of(state.context).colorScheme.error,
|
|
fontSize: 12,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
}
|
|
```
|
|
|
|
### Date Picker FormField
|
|
|
|
```dart
|
|
class DatePickerFormField extends FormField<DateTime> {
|
|
DatePickerFormField({
|
|
super.key,
|
|
super.initialValue,
|
|
super.validator,
|
|
super.onSaved,
|
|
String? labelText,
|
|
DateTime? firstDate,
|
|
DateTime? lastDate,
|
|
}) : super(
|
|
builder: (state) {
|
|
return GestureDetector(
|
|
onTap: () async {
|
|
final picked = await showDatePicker(
|
|
context: state.context,
|
|
initialDate: state.value ?? DateTime.now(),
|
|
firstDate: firstDate ?? DateTime(1900),
|
|
lastDate: lastDate ?? DateTime(2100),
|
|
);
|
|
if (picked != null) {
|
|
state.didChange(picked);
|
|
}
|
|
},
|
|
child: InputDecorator(
|
|
decoration: InputDecoration(
|
|
labelText: labelText,
|
|
errorText: state.errorText,
|
|
suffixIcon: const Icon(Icons.calendar_today),
|
|
),
|
|
child: Text(
|
|
state.value != null
|
|
? DateFormat.yMMMd().format(state.value!)
|
|
: 'Select date',
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
```
|
|
|
|
## Form with Hooks
|
|
|
|
```dart
|
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
|
|
|
class HookLoginForm extends HookWidget {
|
|
const HookLoginForm({super.key});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final formKey = useMemoized(GlobalKey<FormState>.new);
|
|
final emailController = useTextEditingController();
|
|
final passwordController = useTextEditingController();
|
|
final emailFocus = useFocusNode();
|
|
final passwordFocus = useFocusNode();
|
|
final isLoading = useState(false);
|
|
|
|
Future<void> submit() async {
|
|
if (!formKey.currentState!.validate()) return;
|
|
|
|
isLoading.value = true;
|
|
try {
|
|
await authService.login(
|
|
email: emailController.text.trim(),
|
|
password: passwordController.text,
|
|
);
|
|
} finally {
|
|
isLoading.value = false;
|
|
}
|
|
}
|
|
|
|
return Form(
|
|
key: formKey,
|
|
child: Column(
|
|
children: [
|
|
TextFormField(
|
|
controller: emailController,
|
|
focusNode: emailFocus,
|
|
decoration: const InputDecoration(labelText: 'Email'),
|
|
textInputAction: TextInputAction.next,
|
|
onFieldSubmitted: (_) => passwordFocus.requestFocus(),
|
|
validator: Validators.email,
|
|
),
|
|
const SizedBox(height: 16),
|
|
TextFormField(
|
|
controller: passwordController,
|
|
focusNode: passwordFocus,
|
|
decoration: const InputDecoration(labelText: 'Password'),
|
|
obscureText: true,
|
|
onFieldSubmitted: (_) => submit(),
|
|
validator: Validators.password,
|
|
),
|
|
const SizedBox(height: 24),
|
|
ElevatedButton(
|
|
onPressed: isLoading.value ? null : submit,
|
|
child: isLoading.value
|
|
? const CircularProgressIndicator()
|
|
: const Text('Login'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
```
|
|
|
|
## Server-Side Validation
|
|
|
|
```dart
|
|
class ServerValidationForm extends StatefulWidget {
|
|
const ServerValidationForm({super.key});
|
|
|
|
@override
|
|
State<ServerValidationForm> createState() => _ServerValidationFormState();
|
|
}
|
|
|
|
class _ServerValidationFormState extends State<ServerValidationForm> {
|
|
final _formKey = GlobalKey<FormState>();
|
|
final _emailController = TextEditingController();
|
|
Map<String, List<String>> _serverErrors = {};
|
|
|
|
String? _emailValidator(String? value) {
|
|
final clientError = Validators.email(value);
|
|
if (clientError != null) return clientError;
|
|
|
|
final serverError = _serverErrors['email'];
|
|
if (serverError != null && serverError.isNotEmpty) {
|
|
return serverError.first;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
Future<void> _submit() async {
|
|
setState(() => _serverErrors = {});
|
|
|
|
if (!_formKey.currentState!.validate()) return;
|
|
|
|
try {
|
|
await api.register(email: _emailController.text);
|
|
} on ValidationException catch (e) {
|
|
setState(() => _serverErrors = e.errors);
|
|
_formKey.currentState!.validate();
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Form(
|
|
key: _formKey,
|
|
child: Column(
|
|
children: [
|
|
TextFormField(
|
|
controller: _emailController,
|
|
decoration: const InputDecoration(labelText: 'Email'),
|
|
validator: _emailValidator,
|
|
onChanged: (_) {
|
|
if (_serverErrors.containsKey('email')) {
|
|
setState(() => _serverErrors.remove('email'));
|
|
}
|
|
},
|
|
),
|
|
ElevatedButton(
|
|
onPressed: _submit,
|
|
child: const Text('Register'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
```
|
|
|
|
## Auto-Save Form
|
|
|
|
```dart
|
|
class AutoSaveForm extends StatefulWidget {
|
|
const AutoSaveForm({super.key});
|
|
|
|
@override
|
|
State<AutoSaveForm> createState() => _AutoSaveFormState();
|
|
}
|
|
|
|
class _AutoSaveFormState extends State<AutoSaveForm> {
|
|
final _formKey = GlobalKey<FormState>();
|
|
Timer? _debounce;
|
|
bool _hasChanges = false;
|
|
|
|
void _onChanged() {
|
|
setState(() => _hasChanges = true);
|
|
|
|
_debounce?.cancel();
|
|
_debounce = Timer(const Duration(seconds: 2), _autoSave);
|
|
}
|
|
|
|
Future<void> _autoSave() async {
|
|
if (!_hasChanges) return;
|
|
if (!_formKey.currentState!.validate()) return;
|
|
|
|
_formKey.currentState!.save();
|
|
await saveToServer();
|
|
setState(() => _hasChanges = false);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_debounce?.cancel();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Form(
|
|
key: _formKey,
|
|
onChanged: _onChanged,
|
|
child: Column(
|
|
children: [
|
|
if (_hasChanges)
|
|
const Text('Saving...', style: TextStyle(color: Colors.grey)),
|
|
TextFormField(
|
|
decoration: const InputDecoration(labelText: 'Title'),
|
|
onSaved: (value) => saveField('title', value),
|
|
),
|
|
TextFormField(
|
|
decoration: const InputDecoration(labelText: 'Description'),
|
|
maxLines: 3,
|
|
onSaved: (value) => saveField('description', value),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
```
|
|
|
|
## Common Keyboard Types
|
|
|
|
| Type | Usage |
|
|
|------|-------|
|
|
| `TextInputType.text` | General text |
|
|
| `TextInputType.emailAddress` | Email with @ keyboard |
|
|
| `TextInputType.phone` | Phone number pad |
|
|
| `TextInputType.number` | Numeric keyboard |
|
|
| `TextInputType.numberWithOptions(decimal: true)` | Numbers with decimal |
|
|
| `TextInputType.multiline` | Multi-line text |
|
|
| `TextInputType.url` | URL with shortcuts |
|
|
|
|
## Form Checklist
|
|
|
|
| Item | Implementation |
|
|
|------|----------------|
|
|
| GlobalKey | `GlobalKey<FormState>()` for form |
|
|
| Dispose controllers | Clean up in `dispose()` |
|
|
| Validation | Client + server-side |
|
|
| Input formatters | Phone, currency, etc. |
|
|
| Keyboard types | Match input type |
|
|
| Text actions | `textInputAction` for flow |
|
|
| Loading state | Disable during submission |
|
|
| Error display | Show below fields |
|
|
|
|
---
|
|
|
|
*Flutter and Material Design are trademarks of Google LLC.*
|