feat: add flutter-dev skill
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
This commit is contained in:
656
skills/flutter-dev/references/forms.md
Normal file
656
skills/flutter-dev/references/forms.md
Normal file
@@ -0,0 +1,656 @@
|
||||
# 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.*
|
||||
Reference in New Issue
Block a user