Flutter: Formulário e Entrada

Formulários são a ponte entre os usuários e seu app — validação frouxa deixa entrar dados ruins; UX pobre afasta os usuários.

📋 Pré-requisitos: Você precisa dominar o seguinte primeiro

1. O Que Você Vai Aprender


2. Uma História Real de um Formulário de Pagamento que Deu Errado

(1) O Problema: Dados Inválidos Causam Falha no Pagamento

Bob digita o número do cartão de crédito na página de checkout do ShopApp mas esquece um dígito. O sistema não o avisa e submete diretamente — o banco rejeita o pagamento. Ele redigita com um espaço extra, e falha novamente. O cupom é pior ainda — ele digita "SAVE20" mas o código real é "save20"; a diferença de maiúsculas/minúsculas aciona um erro. Após cada falha de pagamento, Bob precisa preencher todos os campos novamente.

(2) A Solução com Validação de Formulário

O mecanismo Form + validator do Flutter intercepta todos os dados inválidos antes da submissão, TextInputFormatter formata a entrada em tempo real, e onSaved coleta tudo uniformemente.

DART
import 'package:flutter/material.dart';

// Validate entire form before submission
if (_formKey.currentState!.validate()) {
  _formKey.currentState!.save();
  // All fields valid, proceed to payment
  processPayment();
}
TEXT
> Saída: Execute localmente com o Flutter SDK (Flutter 3.x / Dart 3.x). O servidor Piston não possui o Flutter instalado; use `flutter run` na sua máquina local para comparação. A UI/estado real pode variar ligeiramente entre plataformas.

(3) Benefício: Zero Submissões Inválidas

Após mudar para validação de Form, Bob obtém verificação em tempo real do formato do cartão de crédito, cupons convertidos automaticamente para maiúsculas, e a taxa de sucesso de pagamento melhora de 70% para 98%.


3. O Sistema de Formulário

(1) Componentes Centrais do Formulário

Widget Responsabilidade Propriedade Chave
Form Contêiner do formulário, gerencia FormFields filhos key: GlobalKey<FormState>
FormField<T> Campo abstrato, gerencia validação/salvamento validator, onSaved
TextFormField FormField de entrada de texto controller, decoration, inputFormatters

▶ Exemplo

TEXT
> Saída: Execute localmente com o Flutter SDK (Flutter 3.x / Dart 3.x). O servidor Piston não possui o Flutter instalado; use `flutter run` na sua máquina local para comparação. A UI/estado real pode variar ligeiramente entre plataformas.

: Estrutura básica de formulário

DART
import 'package:flutter/material.dart';

class CheckoutForm extends StatefulWidget {
  const CheckoutForm({super.key});

  @override
  State<CheckoutForm> createState() => _CheckoutFormState();
}

class _CheckoutFormState extends State<CheckoutForm> {
  final _formKey = GlobalKey<FormState>();

  @override
  Widget build(BuildContext context) {
    return Form(
      key: _formKey,
      child: Column(
        children: [
          TextFormField(
            decoration: const InputDecoration(labelText: 'Full Name'),
            validator: (value) {
              if (value == null || value.isEmpty) return 'Name is required';
              if (value.length < 2) return 'Name too short';
              return null;
            },
            onSaved: (value) => _name = value!,
          ),
          TextFormField(
            decoration: const InputDecoration(labelText: 'Email'),
            validator: (value) {
              if (value == null || !value.contains('@')) return 'Invalid email';
              return null;
            },
          ),
          ElevatedButton(
            onPressed: _submit,
            child: const Text('Submit Order'),
          ),
        ],
      ),
    );
  }

  void _submit() {
    if (_formKey.currentState!.validate()) {
      _formKey.currentState!.save();
      // Process order
    }
  }
}
TEXT
> Saída: Execute localmente com o Flutter SDK (Flutter 3.x / Dart 3.x). O servidor Piston não possui o Flutter instalado; use `flutter run` na sua máquina local para comparação. A UI/estado real pode variar ligeiramente entre plataformas.

4. Mecanismo de Validação

(1) AutovalidateMode

Modo Momento do Gatilho Caso de Uso
disabled Apenas na chamada manual de validate() Padrão, validar ao submeter
always Cada mudança de entrada Feedback em tempo real
onUserInteraction Após a primeira interação do usuário Equilibrar UX e interrupção

▶ Exemplo

TEXT
> Saída: Execute localmente com o Flutter SDK (Flutter 3.x / Dart 3.x). O servidor Piston não possui o Flutter instalado; use `flutter run` na sua máquina local para comparação. A UI/estado real pode variar ligeiramente entre plataformas.

: Validação de número de cartão de crédito

DART
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';

TextFormField(
  decoration: const InputDecoration(
    labelText: 'Card Number',
    hintText: '1234 5678 9012 3456',
    prefixIcon: Icon(Icons.credit_card),
  ),
  keyboardType: TextInputType.number,
  inputFormatters: [
    FilteringTextInputFormatter.digitsOnly,
    CardNumberFormatter(),
  ],
  validator: (value) {
    if (value == null || value.isEmpty) return 'Card number is required';
    final digits = value.replaceAll(' ', '');
    if (digits.length != 16) return 'Card number must be 16 digits';
    if (!_luhnCheck(digits)) return 'Invalid card number';
    return null;
  },
  onSaved: (value) => _cardNumber = value!.replaceAll(' ', ''),
)

// Luhn algorithm for card validation
bool _luhnCheck(String digits) {
  int sum = 0;
  for (int i = 0; i < digits.length; i++) {
    int d = int.parse(digits[digits.length - 1 - i]);
    if (i % 2 == 1) {
      d *= 2;
      if (d > 9) d -= 9;
    }
    sum += d;
  }
  return sum % 10 == 0;
}
TEXT
> Saída: Execute localmente com o Flutter SDK (Flutter 3.x / Dart 3.x). O servidor Piston não possui o Flutter instalado; use `flutter run` na sua máquina local para comparação. A UI/estado real pode variar ligeiramente entre plataformas.

▶ Exemplo

TEXT
> Saída: Execute localmente com o Flutter SDK (Flutter 3.x / Dart 3.x). O servidor Piston não possui o Flutter instalado; use `flutter run` na sua máquina local para comparação. A UI/estado real pode variar ligeiramente entre plataformas.

: Validação assíncrona (cupom)

DART
import 'package:flutter/material.dart';

// Simplified class definition
class CouponService {
  static Future<bool> validate(String code) async => code == 'SAVE20';
}

TextFormField(
  decoration: const InputDecoration(labelText: 'Coupon Code'),
  autovalidateMode: AutovalidateMode.onUserInteraction,
  validator: (value) async {
    if (value == null || value.isEmpty) return null;
    if (value.length < 4) return 'Code too short';
    final isValid = await CouponService.validate(value.toUpperCase());
    if (!isValid) return 'Invalid coupon code';
    return null;
  },
)
TEXT
> Saída: Execute localmente com o Flutter SDK (Flutter 3.x / Dart 3.x). O servidor Piston não possui o Flutter instalado; use `flutter run` na sua máquina local para comparação. A UI/estado real pode variar ligeiramente entre plataformas.

5. Controle de Formato com TextInputFormatter

(1) Formatadores Comuns

Formatador Propósito Exemplo Entrada → Saída
FilteringTextInputFormatter.digitsOnly Apenas dígitos "abc123" → "123"
FilteringTextInputFormatter.allow(RegExp) Permitir padrão correspondente Regex personalizado
FilteringTextInputFormatter.deny(RegExp) Negar padrão correspondente Regex personalizado
Formatador personalizado Qualquer formatação Cartão/moeda/telefone

▶ Exemplo

TEXT
> Saída: Execute localmente com o Flutter SDK (Flutter 3.x / Dart 3.x). O servidor Piston não possui o Flutter instalado; use `flutter run` na sua máquina local para comparação. A UI/estado real pode variar ligeiramente entre plataformas.

: Formatador de número de cartão de crédito

DART
import 'package:flutter/services.dart';

class CardNumberFormatter extends TextInputFormatter {
  @override
  TextEditingValue formatEditUpdate(
    TextEditingValue oldValue,
    TextEditingValue newValue,
  ) {
    final digits = newValue.text.replaceAll(' ', '');
    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]);
    }
    final formatted = buffer.toString();
    return TextEditingValue(
      text: formatted,
      selection: TextSelection.collapsed(offset: formatted.length),
    );
  }
}
TEXT
> Saída: Execute localmente com o Flutter SDK (Flutter 3.x / Dart 3.x). O servidor Piston não possui o Flutter instalado; use `flutter run` na sua máquina local para comparação. A UI/estado real pode variar ligeiramente entre plataformas.

▶ Exemplo

TEXT
> Saída: Execute localmente com o Flutter SDK (Flutter 3.x / Dart 3.x). O servidor Piston não possui o Flutter instalado; use `flutter run` na sua máquina local para comparação. A UI/estado real pode variar ligeiramente entre plataformas.

: Formatador de entrada de moeda

DART
import 'package:flutter/services.dart';

class CurrencyFormatter extends TextInputFormatter {
  @override
  TextEditingValue formatEditUpdate(
    TextEditingValue oldValue,
    TextEditingValue newValue,
  ) {
    // Only allow digits and one decimal point
    final text = newValue.text.replaceAll(RegExp(r'[^\d.]'), '');
    final parts = text.split('.');
    if (parts.length > 2) return oldValue;
    if (parts.length == 2 && parts[1].length > 2) return oldValue;
    return TextEditingValue(
      text: text,
      selection: TextSelection.collapsed(offset: text.length),
    );
  }
}
TEXT
> Saída: Execute localmente com o Flutter SDK (Flutter 3.x / Dart 3.x). O servidor Piston não possui o Flutter instalado; use `flutter run` na sua máquina local para comparação. A UI/estado real pode variar ligeiramente entre plataformas.

6. Gerenciamento de Estado do Formulário

▶ Exemplo

TEXT
> Saída: Execute localmente com o Flutter SDK (Flutter 3.x / Dart 3.x). O servidor Piston não possui o Flutter instalado; use `flutter run` na sua máquina local para comparação. A UI/estado real pode variar ligeiramente entre plataformas.

: Salvar e resetar formulário

DART
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';

// CardNumberFormatter class definition see above

class _CheckoutFormState extends State<CheckoutForm> {
  final _formKey = GlobalKey<FormState>();
  String _name = '';
  String _address = '';
  String _cardNumber = '';

  void _submit() {
    final form = _formKey.currentState!;
    if (form.validate()) {
      form.save(); // Triggers all onSaved callbacks
      processOrder(name: _name, address: _address, card: _cardNumber);
    }
  }

  void _reset() {
    _formKey.currentState!.reset(); // Clears all fields and errors
  }

  @override
  Widget build(BuildContext context) {
    return Form(
      key: _formKey,
      child: Column(children: [
        TextFormField(
          decoration: const InputDecoration(labelText: 'Full Name'),
          onSaved: (v) => _name = v ?? '',
        ),
        TextFormField(
          decoration: const InputDecoration(labelText: 'Shipping Address'),
          onSaved: (v) => _address = v ?? '',
        ),
        TextFormField(
          decoration: const InputDecoration(labelText: 'Card Number'),
          inputFormatters: [FilteringTextInputFormatter.digitsOnly, CardNumberFormatter()],
          onSaved: (v) => _cardNumber = v?.replaceAll(' ', '') ?? '',
        ),
        const SizedBox(height: 20),
        Row(children: [
          Expanded(child: OutlinedButton(onPressed: _reset, child: const Text('Reset'))),
          const SizedBox(width: 12),
          Expanded(child: FilledButton(onPressed: _submit, child: const Text('Pay Now'))),
        ]),
      ]),
    );
  }
}
TEXT
> Saída: Execute localmente com o Flutter SDK (Flutter 3.x / Dart 3.x). O servidor Piston não possui o Flutter instalado; use `flutter run` na sua máquina local para comparação. A UI/estado real pode variar ligeiramente entre plataformas.

7. Exemplo Completo: Página de Checkout do ShopApp

⚙️ Instalar dependência: flutter pub add go_router

DART
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:go_router/go_router.dart';

// CardNumberFormatter class definition see above

// Simplified class definition
class ExpiryDateFormatter extends TextInputFormatter {
  @override
  TextEditingValue formatEditUpdate(TextEditingValue oldValue, TextEditingValue newValue) {
    final text = newValue.text.replaceAll(RegExp(r'[^\d]'), '');
    final buffer = StringBuffer();
    for (int i = 0; i < text.length && i < 4; i++) {
      if (i == 2) buffer.write('/');
      buffer.write(text[i]);
    }
    final formatted = buffer.toString();
    return TextEditingValue(text: formatted, selection: TextSelection.collapsed(offset: formatted.length));
  }
}

class CouponService {
  static Future<double> validateAndGetDiscount(String code) async {
    await Future.delayed(const Duration(milliseconds: 500));
    if (code == 'SAVE20') return 0.2;
    return 0;
  }
}

class CheckoutPage extends StatefulWidget {
  final double total;
  const CheckoutPage({super.key, required this.total});

  @override
  State<CheckoutPage> createState() => _CheckoutPageState();
}

class _CheckoutPageState extends State<CheckoutPage> {
  final _formKey = GlobalKey<FormState>();
  final _couponController = TextEditingController();
  String? _couponError;
  double _discount = 0;

  void _applyCoupon() async {
    final code = _couponController.text.toUpperCase();
    if (code.length < 4) {
      setState(() => _couponError = 'Code too short');
      return;
    }
    final discount = await CouponService.validateAndGetDiscount(code);
    if (discount > 0) {
      setState(() { _discount = discount; _couponError = null; });
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text('${(discount * 100).toStringAsFixed(0)}% discount applied!')),
      );
    } else {
      setState(() => _couponError = 'Invalid coupon code');
    }
  }

  void _submitOrder() {
    if (!_formKey.currentState!.validate()) return;
    _formKey.currentState!.save();
    showDialog(context: context, builder: (_) => AlertDialog(
      title: const Text('Order Confirmed'),
      content: Text('Total: \$${(widget.total * (1 - _discount)).toStringAsFixed(2)}'),
      actions: [FilledButton(onPressed: () => context.go('/'), child: const Text('OK'))],
    ));
  }

  @override
  Widget build(BuildContext context) {
    final finalTotal = widget.total * (1 - _discount);
    return Scaffold(
      appBar: AppBar(title: const Text('Checkout')),
      body: SingleChildScrollView(
        padding: const EdgeInsets.all(16),
        child: Form(key: _formKey, child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            const Text('Shipping Address', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
            const SizedBox(height: 12),
            TextFormField(decoration: const InputDecoration(labelText: 'Full Name', prefixIcon: Icon(Icons.person)),
              validator: (v) => v?.isEmpty ?? true ? 'Required' : null, onSaved: (v) {}),
            const SizedBox(height: 12),
            TextFormField(decoration: const InputDecoration(labelText: 'Address Line 1', prefixIcon: Icon(Icons.home)),
              validator: (v) => v?.isEmpty ?? true ? 'Required' : null, onSaved: (v) {}),
            const SizedBox(height: 12),
            Row(children: [
              Expanded(child: TextFormField(decoration: const InputDecoration(labelText: 'City'),
                validator: (v) => v?.isEmpty ?? true ? 'Required' : null)),
              const SizedBox(width: 12),
              Expanded(child: TextFormField(decoration: const InputDecoration(labelText: 'Zip Code'),
                keyboardType: TextInputType.number, inputFormatters: [FilteringTextInputFormatter.digitsOnly])),
            ]),
            const Divider(height: 32),
            const Text('Payment', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
            const SizedBox(height: 12),
            TextFormField(decoration: const InputDecoration(labelText: 'Card Number', prefixIcon: Icon(Icons.credit_card)),
              keyboardType: TextInputType.number, inputFormatters: [FilteringTextInputFormatter.digitsOnly, CardNumberFormatter()],
              validator: (v) => (v?.replaceAll(' ', '').length ?? 0) != 16 ? 'Invalid card number' : null),
            const SizedBox(height: 12),
            Row(children: [
              Expanded(child: TextFormField(decoration: const InputDecoration(labelText: 'MM/YY'),
                keyboardType: TextInputType.number, inputFormatters: [ExpiryDateFormatter()],
                validator: (v) => v?.length != 5 ? 'Invalid' : null)),
              const SizedBox(width: 12),
              Expanded(child: TextFormField(decoration: const InputDecoration(labelText: 'CVV'),
                keyboardType: TextInputType.number, inputFormatters: [FilteringTextInputFormatter.digitsOnly, LengthLimitingTextInputFormatter(3)],
                obscureText: true, validator: (v) => v?.length != 3 ? 'Invalid' : null)),
            ]),
            const Divider(height: 32),
            TextFormField(controller: _couponController, decoration: InputDecoration(
              labelText: 'Coupon Code', errorText: _couponError,
              suffixIcon: TextButton(onPressed: _applyCoupon, child: const Text('Apply'))),
            ),
            const SizedBox(height: 24),
            Container(padding: const EdgeInsets.all(16), decoration: BoxDecoration(color: Colors.grey[100], borderRadius: BorderRadius.circular(12)),
              child: Column(children: [
                Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
                  const Text('Subtotal'), Text('\$${widget.total.toStringAsFixed(2)}')]),
                if (_discount > 0) Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
                  Text('Discount (${(_discount * 100).toStringAsFixed(0)}%)'),
                  Text('-\$${(widget.total * _discount).toStringAsFixed(2)}', style: const TextStyle(color: Colors.green))]),
                const Divider(),
                Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
                  const Text('Total', style: TextStyle(fontWeight: FontWeight.bold)),
                  Text('\$${finalTotal.toStringAsFixed(2)}', style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold))]),
              ])),
            const SizedBox(height: 16),
            SizedBox(width: double.infinity, child: FilledButton.icon(
              onPressed: _submitOrder, icon: const Icon(Icons.lock), label: const Text('Pay \$${finalTotal.toStringAsFixed(2)}'),
              style: FilledButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)))),
          ],
        )),
      ),
    );
  }
}
TEXT
> Saída: Execute localmente com o Flutter SDK (Flutter 3.x / Dart 3.x). O servidor Piston não possui o Flutter instalado; use `flutter run` na sua máquina local para comparação. A UI/estado real pode variar ligeiramente entre plataformas.

❓ Perguntas Frequentes

P: Por que usar GlobalKey<FormState> como chave global? R: Os métodos validate/save/reset do FormState precisam ser chamados de fora do widget. GlobalKey permite acesso ao estado do Form entre widgets.

P: Como usar validadores assíncronos? R: O validator retorna Future<String?>, nativamente suportado no Flutter 3.x. Durante a validação assíncrona, o campo mostra um indicador de carregamento.

P: Quando o formatEditUpdate do TextInputFormatter é acionado? R: É acionado a cada caractere inserido, antes do validator rodar. Ideal para formatação em tempo real (ex.: adicionar espaços aos números do cartão).

P: Como persistir dados do formulário (sobreviver à rotação de tela)? R: Use TextEditingController para manter dados, salve no SharedPreferences no dispose. Ou use Riverpod para gerenciar o estado do formulário.

P: Qual a diferença entre validator retornar null vs uma string vazia? R: null significa validação passou; qualquer string não nula é exibida como erro. Retornar uma string vazia mostra uma área de erro em branco.

P: Como validar campos que dependem uns dos outros? R: Acesse valores de outros campos via setState ou controller dentro do validator. Por exemplo, um campo de confirmação de senha lê o valor do campo de senha para comparação.


📖 Resumo


📝 Exercícios

  1. Básico (⭐): Crie um formulário de login com campos de email e senha — valide o formato do email e o comprimento da senha ≥ 8.
  2. Intermediário (⭐⭐): Implemente entrada de número de cartão de crédito: auto-adicionar espaços a cada 4 dígitos, validação em tempo real do algoritmo Luhn, formatação de data MM/AA.
  3. Desafio (⭐⭐⭐): Implemente um formulário de checkout completo: endereço de entrega + cartão de crédito + validação assíncrona de cupom + cálculo automático do total com desconto.

← Aula Anterior | Próxima Aula →

Web-Tutorial.com

Equipe Técnica Web-Tutorial

Uma plataforma de tutoriais mantida por diversos desenvolvedores. Cada tutorial é escrito e revisado por profissionais da área correspondente. Trabalhamos para manter nosso conteúdo preciso e confiável — se encontrar algum problema, avise-nos.

100%