flutter-forms
Skill12 starsChanged 55 days agoMITView on GitHub
Use when building a Flutter form or sign-up screen - field validation, wiring to a Cubit, input masks and pt-BR formatting (CPF, CNPJ, phone, postcode, currency, dates), or a multi-step form. Triggers on "registration screen", "validate this field" or "digits only".
1 other project uses a version of this.
Adopted by
Most recently taken up by
- lubshad/skillstheir own version, first seen Nov 1, 2026
Other versions1 project uses a version of this.
Word for word · 0
None found.
Edited · 0
None found.
Wrote their own version · 1
- lubshad/skills★ 1same name
How matches are found. Word for word: the exact same text. Edited: shares passages with this file (“99% same” means 99% of its five-word phrases also appear here; 10% or more counts). Their own version: similar meaning, or, weakest, the same name. We can’t tell which project had it first. Projects are counted among the 200 most-starred candidates, and mentions are links to the repository we found, up to 50 per site, so both counts are a floor.
In this repository15 other files it gives its agents.
- flutter-accessibilitySkill
- flutter-cachingSkill
- flutter-clean-archSkill
- flutter-concurrencySkill
- flutter-dart-styleSkill
- flutter-design-systemSkill
- flutter-diSkill
- flutter-monorepoSkill
- flutter-new-moduleSkill
- flutter-runSkill
- flutter-stateSkill
- flutter-store-cdSkill
- flutter-testingSkill
- flutter-themingSkill
- flutter-webSkill
The file
We split this skill into the rules it gives an agent
10 rules. About 0 are common, 3 are distinctive, and 7 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈3
Distinctive · estimate
This author's own choices. Worth a look.
7
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Distinctive: line 20
Every form follows this structure:
- Very specific: line 383
Use AutomaticKeepAliveClientMixin to preserve form state when switching tabs.
- Very specific: line 509
Create a utility class in core/shared/validators.dart.
- Very specific: line 754
In dialogs: call widget.onSave(entity) callback instead of using a controller directly, since the parent screen owns the controller
- Distinctive: line 778
Read the existing screen/dialog if one already exists in the module
- Very specific: line 780
If a Validators class does not yet exist in core/shared/, create one
- Very specific: line 781
Use MoneyMaskedTextController from flutter_masked_text3 for any price/currency fields
- Very specific: line 782
Always dispose all controllers in dispose()
- Very specific: line 783
Never recreate GlobalKey<FormState> inside build() — always declare as a class field
- Distinctive: line 786
Use Portuguese strings for user-facing validation messages (e.g., "Campo obrigatorio")
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
You are implementing a form for a Flutter feature.
Three Patterns by Use Case
Choose based on the skill argument, if one was given, or on the context of the feature:
simple— basic form with minimal validation (settings toggle, quick input)validated— full validation, field-level errors, submit-to-Cubit flow (login, product creation)multi-step— form split across tabs or stepper (store config, onboarding)
Form Architecture
Every form follows this structure:
- StatefulWidget — forms need mutable state for controllers and keys
- GlobalKey
<FormState>— declared as a field, NEVER recreated inbuild() - TextEditingController per field — initialized in
initState()or inline, disposed indispose() - Submit via
_formKey.currentState!.validate()— returns true only if all validators pass
class <Feature>Form extends StatefulWidget {
final <Entity>? initial; // null = create mode, non-null = edit mode
const <Feature>Form({super.key, this.initial});
@override
State<<Feature>Form> createState() => _<Feature>FormState();
}
class _<Feature>FormState extends State<<Feature>Form> {
// CORRECT: key is a field, created once
final _formKey = GlobalKey<FormState>();
final _nameController = TextEditingController();
@override
void initState() {
super.initState();
if (widget.initial != null) {
_nameController.text = widget.initial!.name;
}
}
@override
void dispose() {
_nameController.dispose();
super.dispose();
}
// ...
}NEVER do this:
// WRONG: recreates key every build, loses form state
@override
Widget build(BuildContext context) {
final formKey = GlobalKey<FormState>(); // BUG!
return Form(key: formKey, child: ...);
}Pattern 1: Simple Form
Best for: quick dialogs, single-field inputs, settings
class _QuickInputDialogState extends State<QuickInputDialog> {
final _formKey = GlobalKey<FormState>();
final _controller = TextEditingController();
void _handleSubmit() {
if (_formKey.currentState!.validate()) {
widget.onSave(_controller.text);
Navigator.of(context).pop();
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Adicionar Item'),
content: Form(
key: _formKey,
child: TextFormField(
controller: _controller,
decoration: const InputDecoration(labelText: 'Nome'),
validator: Validators.required('Digite o nome'),
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancelar'),
),
FilledButton(
onPressed: _handleSubmit,
child: const Text('Salvar'),
),
],
);
}
}Pattern 2: Validated Form + Cubit Integration
Best for: login, product creation, user management
Submission flow:
User fills form -> taps submit -> _formKey.validate()
-> if valid -> controller.submit(data)
-> BlocListener shows success/errorLogin form example:
class LoginScreen extends StatefulWidget {
const LoginScreen({super.key});
@override
State<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends State<LoginScreen> {
final _formKey = GlobalKey<FormState>();
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
bool _obscurePassword = true;
@override
void dispose() {
_emailController.dispose();
_passwordController.dispose();
super.dispose();
}
void _handleSubmit() {
if (_formKey.currentState!.validate()) {
context.read<AuthController>().login(
email: _emailController.text.trim(),
password: _passwordController.text,
);
}
}
@override
Widget build(BuildContext context) {
return BlocListener<AuthController, AuthStatus>(
listener: (context, status) {
if (status is AuthSuccess) {
Navigator.pushReplacementNamed(context, '/home');
} else if (status is AuthFailure) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(status.message)),
);
}
},
child: Scaffold(
body: Form(
key: _formKey,
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
TextFormField(
controller: _emailController,
decoration: const InputDecoration(
labelText: 'E-mail',
prefixIcon: Icon(Icons.email_outlined),
),
keyboardType: TextInputType.emailAddress,
validator: Validators.compose([
Validators.required('Digite seu e-mail'),
Validators.email('E-mail invalido'),
]),
),
const SizedBox(height: 16),
TextFormField(
controller: _passwordController,
obscureText: _obscurePassword,
decoration: InputDecoration(
labelText: 'Senha',
prefixIcon: const Icon(Icons.lock_outlined),
suffixIcon: IconButton(
icon: Icon(_obscurePassword
? Icons.visibility_off
: Icons.visibility),
onPressed: () =>
setState(() => _obscurePassword = !_obscurePassword),
),
),
validator: Validators.required('Digite sua senha'),
),
const SizedBox(height: 24),
BlocBuilder<AuthController, AuthStatus>(
builder: (context, status) {
final isLoading = status is AuthLoading;
return FilledButton(
onPressed: isLoading ? null : _handleSubmit,
child: isLoading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Entrar'),
);
},
),
],
),
),
),
),
);
}
}Product creation form example (with money input and image picker):
class _ProductDialogState extends State<ProductDialog> {
final _formKey = GlobalKey<FormState>();
final _nameController = TextEditingController();
final _codeController = TextEditingController();
final _descriptionController = TextEditingController();
late final _priceController = MoneyMaskedTextController(
initialValue: widget.product?.price ?? 0,
leftSymbol: 'R\$',
decimalSeparator: ',',
thousandSeparator: '.',
precision: 2,
);
Category? _selectedCategory;
final List<XFile> _newImages = [];
List<String> _existingImages = [];
@override
void initState() {
super.initState();
if (widget.product != null) {
_nameController.text = widget.product!.name;
_codeController.text = widget.product!.code.toString();
_descriptionController.text = widget.product!.description;
_selectedCategory = widget.product!.category;
_existingImages = List.from(widget.product!.images);
}
}
@override
void dispose() {
_nameController.dispose();
_codeController.dispose();
_descriptionController.dispose();
_priceController.dispose();
super.dispose();
}
Future<void> _pickImages() async {
final picker = ImagePicker();
final images = await picker.pickMultiImage();
if (images.isNotEmpty) {
setState(() => _newImages.addAll(images));
}
}
void _handleSubmit() {
if (_formKey.currentState!.validate()) {
final product = Product(
id: widget.product?.id ?? '',
name: _nameController.text,
code: int.tryParse(_codeController.text) ?? 0,
price: _priceController.numberValue,
description: _descriptionController.text,
category: _selectedCategory!,
images: _existingImages,
);
widget.onSave(product, _newImages);
Navigator.of(context).pop();
}
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text(widget.product == null ? 'Novo Produto' : 'Editar Produto'),
content: Form(
key: _formKey,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextFormField(
controller: _nameController,
decoration: const InputDecoration(labelText: 'Nome do Produto'),
validator: Validators.required('Digite o nome do produto'),
),
const SizedBox(height: 16),
TextFormField(
controller: _priceController,
decoration: const InputDecoration(labelText: 'Preco'),
keyboardType: TextInputType.number,
validator: Validators.minValue(0.01, 'Preco deve ser maior que zero'),
),
const SizedBox(height: 16),
DropdownButtonFormField<Category>(
value: _selectedCategory,
decoration: const InputDecoration(labelText: 'Categoria'),
items: widget.categories
.map((c) => DropdownMenuItem(value: c, child: Text(c.name)))
.toList(),
onChanged: (value) => setState(() => _selectedCategory = value),
validator: (value) =>
value == null ? 'Selecione uma categoria' : null,
),
const SizedBox(height: 16),
TextFormField(
controller: _descriptionController,
decoration: const InputDecoration(labelText: 'Descricao'),
maxLines: 3,
),
const SizedBox(height: 16),
// Image picker section
Wrap(
spacing: 8,
children: [
..._existingImages.map((url) => _ImageTile(
imageUrl: url,
onRemove: () => setState(
() => _existingImages.remove(url)),
)),
IconButton.filled(
onPressed: _pickImages,
icon: const Icon(Icons.add_a_photo),
),
],
),
],
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancelar'),
),
FilledButton(
onPressed: _handleSubmit,
child: const Text('Salvar'),
),
],
);
}
}Pattern 3: Multi-Step Form (TabBarView)
Best for: store configuration, onboarding, complex entity creation
Use AutomaticKeepAliveClientMixin to preserve form state when switching tabs.
class StoreConfigScreen extends StatefulWidget {
const StoreConfigScreen({super.key});
@override
State<StoreConfigScreen> createState() => _StoreConfigScreenState();
}
class _StoreConfigScreenState extends State<StoreConfigScreen>
with SingleTickerProviderStateMixin {
late final TabController _tabController;
// One form key per tab — each tab validates independently
final _infoFormKey = GlobalKey<FormState>();
final _hoursFormKey = GlobalKey<FormState>();
final _paymentFormKey = GlobalKey<FormState>();
final _storeNameController = TextEditingController();
final _phoneController = TextEditingController();
@override
void initState() {
super.initState();
_tabController = TabController(length: 3, vsync: this);
}
@override
void dispose() {
_tabController.dispose();
_storeNameController.dispose();
_phoneController.dispose();
super.dispose();
}
bool _validateAllTabs() {
final infoValid = _infoFormKey.currentState?.validate() ?? false;
final hoursValid = _hoursFormKey.currentState?.validate() ?? false;
final paymentValid = _paymentFormKey.currentState?.validate() ?? false;
if (!infoValid) _tabController.animateTo(0);
else if (!hoursValid) _tabController.animateTo(1);
else if (!paymentValid) _tabController.animateTo(2);
return infoValid && hoursValid && paymentValid;
}
void _handleSubmit() {
if (_validateAllTabs()) {
context.read<StoreConfigController>().save(/* collected data */);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Configuracoes da Loja'),
bottom: TabBar(
controller: _tabController,
tabs: const [
Tab(text: 'Informacoes'),
Tab(text: 'Horarios'),
Tab(text: 'Pagamento'),
],
),
),
body: TabBarView(
controller: _tabController,
children: [
_StoreInfoTab(formKey: _infoFormKey, nameController: _storeNameController),
_StoreHoursTab(formKey: _hoursFormKey),
_PaymentConfigTab(formKey: _paymentFormKey),
],
),
floatingActionButton: FloatingActionButton.extended(
onPressed: _handleSubmit,
label: const Text('Salvar'),
icon: const Icon(Icons.save),
),
);
}
}
/// Each tab uses AutomaticKeepAliveClientMixin to preserve state
class _StoreInfoTab extends StatefulWidget {
final GlobalKey<FormState> formKey;
final TextEditingController nameController;
const _StoreInfoTab({required this.formKey, required this.nameController});
@override
State<_StoreInfoTab> createState() => _StoreInfoTabState();
}
class _StoreInfoTabState extends State<_StoreInfoTab>
with AutomaticKeepAliveClientMixin {
@override
bool get wantKeepAlive => true;
@override
Widget build(BuildContext context) {
super.build(context); // Required by AutomaticKeepAliveClientMixin
return Form(
key: widget.formKey,
child: ListView(
padding: const EdgeInsets.all(16),
children: [
TextFormField(
controller: widget.nameController,
decoration: const InputDecoration(labelText: 'Nome da Loja'),
validator: Validators.required('Digite o nome da loja'),
),
// ... more fields
],
),
);
}
}Reusable Validators
Create a utility class in core/shared/validators.dart. Each validator is a function that returns a FormFieldValidator<String> (i.e., String? Function(String?)).
/// core/shared/validators.dart
class Validators {
Validators._();
/// Composes multiple validators — runs in order, returns first error
static FormFieldValidator<String> compose(
List<FormFieldValidator<String>> validators) {
return (value) {
for (final validator in validators) {
final error = validator(value);
if (error != null) return error;
}
return null;
};
}
/// Field must not be empty
static FormFieldValidator<String> required(String message) {
return (value) =>
(value == null || value.trim().isEmpty) ? message : null;
}
/// Valid email format
static FormFieldValidator<String> email(String message) {
return (value) {
if (value == null || value.isEmpty) return null; // use with required()
final regex = RegExp(r'^[\w\.\-]+@[\w\.\-]+\.\w{2,}$');
return regex.hasMatch(value) ? null : message;
};
}
/// Minimum character length
static FormFieldValidator<String> minLength(int min, String message) {
return (value) =>
(value != null && value.length < min) ? message : null;
}
/// Maximum character length
static FormFieldValidator<String> maxLength(int max, String message) {
return (value) =>
(value != null && value.length > max) ? message : null;
}
/// Numeric value only
static FormFieldValidator<String> numeric(String message) {
return (value) {
if (value == null || value.isEmpty) return null;
return double.tryParse(value) == null ? message : null;
};
}
/// Minimum numeric value (for MoneyMaskedTextController fields)
static FormFieldValidator<String> minValue(
double min, String message) {
return (value) {
if (value == null || value.isEmpty) return null;
final cleaned =
value.replaceAll('R\$', '').replaceAll('.', '').replaceAll(',', '.');
final number = double.tryParse(cleaned.trim());
return (number == null || number < min) ? message : null;
};
}
/// Password strength: min 6 chars
static FormFieldValidator<String> password(String message) {
return (value) =>
(value != null && value.length < 6) ? message : null;
}
/// Matches another field (password confirmation)
static FormFieldValidator<String> matches(
TextEditingController other, String message) {
return (value) => (value != other.text) ? message : null;
}
}Usage with compose:
TextFormField(
validator: Validators.compose([
Validators.required('Campo obrigatorio'),
Validators.email('E-mail invalido'),
]),
),Field Type Reference
TextFormField (standard)
TextFormField(
controller: _controller,
decoration: const InputDecoration(labelText: 'Nome'),
validator: Validators.required('Campo obrigatorio'),
)DropdownButtonFormField
DropdownButtonFormField<Category>(
value: _selectedCategory,
decoration: const InputDecoration(labelText: 'Categoria'),
items: categories
.map((c) => DropdownMenuItem(value: c, child: Text(c.name)))
.toList(),
onChanged: (value) => setState(() => _selectedCategory = value),
validator: (value) => value == null ? 'Selecione uma categoria' : null,
)Money Input (Brazilian Real)
Uses flutter_masked_text3 package, which is already a dependency in the project.
late final _priceController = MoneyMaskedTextController(
initialValue: widget.product?.price ?? 0,
leftSymbol: 'R\$',
decimalSeparator: ',',
thousandSeparator: '.',
precision: 2,
);
// In form:
TextFormField(
controller: _priceController,
decoration: const InputDecoration(labelText: 'Preco'),
keyboardType: TextInputType.number,
validator: Validators.minValue(0.01, 'Preco deve ser maior que zero'),
)
// Reading the value:
final double price = _priceController.numberValue;Password Field with Obscure Toggle
bool _obscurePassword = true;
TextFormField(
controller: _passwordController,
obscureText: _obscurePassword,
decoration: InputDecoration(
labelText: 'Senha',
prefixIcon: const Icon(Icons.lock_outlined),
suffixIcon: IconButton(
icon: Icon(
_obscurePassword ? Icons.visibility_off : Icons.visibility),
onPressed: () =>
setState(() => _obscurePassword = !_obscurePassword),
),
),
validator: Validators.compose([
Validators.required('Digite sua senha'),
Validators.password('Senha deve ter no minimo 6 caracteres'),
]),
)Password Confirmation
TextFormField(
controller: _confirmPasswordController,
obscureText: true,
decoration: const InputDecoration(labelText: 'Confirmar Senha'),
validator: Validators.compose([
Validators.required('Confirme sua senha'),
Validators.matches(_passwordController, 'As senhas nao coincidem'),
]),
)Date Picker Field
DateTime? _selectedDate;
FormField<DateTime>(
initialValue: _selectedDate,
validator: (value) => value == null ? 'Selecione uma data' : null,
builder: (field) {
return InkWell(
onTap: () async {
final picked = await showDatePicker(
context: context,
initialDate: _selectedDate ?? DateTime.now(),
firstDate: DateTime(2020),
lastDate: DateTime(2030),
);
if (picked != null) {
setState(() => _selectedDate = picked);
field.didChange(picked);
}
},
child: InputDecorator(
decoration: InputDecoration(
labelText: 'Data',
errorText: field.errorText,
),
child: Text(
_selectedDate != null
? '${_selectedDate!.day}/${_selectedDate!.month}/${_selectedDate!.year}'
: 'Selecione',
),
),
);
},
)Image Picker (Product Photos)
final List<XFile> _newImages = [];
List<String> _existingImages = [];
Future<void> _pickImages() async {
if (_existingImages.length + _newImages.length >= 5) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Maximo de 5 imagens permitido.')),
);
return;
}
final picker = ImagePicker();
final images = await picker.pickMultiImage();
if (images.isNotEmpty) {
setState(() {
final totalAllowed = 5 - _existingImages.length - _newImages.length;
_newImages.addAll(images.take(totalAllowed));
});
}
}Form + Cubit Integration Rules
- Form collects data, controller processes it — the form widget builds the entity/DTO, then calls
controller.submit(data) - Use
BlocListenerfor side effects — navigation, snackbar, dialog. Never navigate insideBlocBuilder - Use
BlocBuilderonly for the submit button — to show loading spinner and disable the button - Controller emits Loading before async work, Success or Failure after — see
/flutter-stateskill - In dialogs: call
widget.onSave(entity)callback instead of using a controller directly, since the parent screen owns the controller
// Screen owns the controller and reacts to state
BlocListener<MenuController, MenuStatus>(
listener: (context, status) {
if (status is MenuSuccess) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Produto salvo com sucesso!')),
);
} else if (status is MenuFailure) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(status.message)),
);
}
},
child: /* form widget */,
),Instructions
- Read the existing screen/dialog if one already exists in the module
- Choose the right pattern (
simple,validated,multi-step) based on the form's complexity - If a
Validatorsclass does not yet exist incore/shared/, create one - Use
MoneyMaskedTextControllerfromflutter_masked_text3for any price/currency fields - Always dispose all controllers in
dispose() - Never recreate
GlobalKey<FormState>insidebuild()— always declare as a class field - For forms inside
TabBarView, useAutomaticKeepAliveClientMixin - Connect form submission to the module's controller following the
/flutter-stateskill patterns - Use Portuguese strings for user-facing validation messages (e.g., "Campo obrigatorio")
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

