agentleFS
gabuldevgabuldev/flutter-skills/skills/flutter-forms/SKILL.md

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

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
  1. Distinctive:

    Every form follows this structure:

    line 20
  2. Very specific:

    Use AutomaticKeepAliveClientMixin to preserve form state when switching tabs.

    line 383
  3. Very specific:

    Create a utility class in core/shared/validators.dart.

    line 509
  4. Very specific:

    In dialogs: call widget.onSave(entity) callback instead of using a controller directly, since the parent screen owns the controller

    line 754
  5. Distinctive:

    Read the existing screen/dialog if one already exists in the module

    line 778
  6. Very specific:

    If a Validators class does not yet exist in core/shared/, create one

    line 780
  7. Very specific:

    Use MoneyMaskedTextController from flutter_masked_text3 for any price/currency fields

    line 781
  8. Very specific:

    Always dispose all controllers in dispose()

    line 782
  9. Very specific:

    Never recreate GlobalKey<FormState> inside build() — always declare as a class field

    line 783
  10. Distinctive:

    Use Portuguese strings for user-facing validation messages (e.g., "Campo obrigatorio")

    line 786

≈ “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.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

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:

  1. StatefulWidget — forms need mutable state for controllers and keys
  2. GlobalKey<FormState> — declared as a field, NEVER recreated in build()
  3. TextEditingController per field — initialized in initState() or inline, disposed in dispose()
  4. 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/error

Login 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

  1. Form collects data, controller processes it — the form widget builds the entity/DTO, then calls controller.submit(data)
  2. Use BlocListener for side effects — navigation, snackbar, dialog. Never navigate inside BlocBuilder
  3. Use BlocBuilder only for the submit button — to show loading spinner and disable the button
  4. Controller emits Loading before async work, Success or Failure after — see /flutter-state skill
  5. 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

  1. Read the existing screen/dialog if one already exists in the module
  2. Choose the right pattern (simple, validated, multi-step) based on the form's complexity
  3. If a Validators class does not yet exist in core/shared/, create one
  4. Use MoneyMaskedTextController from flutter_masked_text3 for any price/currency fields
  5. Always dispose all controllers in dispose()
  6. Never recreate GlobalKey<FormState> inside build() — always declare as a class field
  7. For forms inside TabBarView, use AutomaticKeepAliveClientMixin
  8. Connect form submission to the module's controller following the /flutter-state skill patterns
  9. Use Portuguese strings for user-facing validation messages (e.g., "Campo obrigatorio")

Download now (ZIP)

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
What did you use it for? e.g. Reviewing pull requests in a Rails app

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.