flowchart TD
A[Usuário preenche o campo] --> B{AutovalidateMode}
B -- disabled --> C[Sem validação automática]
B -- onUserInteraction --> D[Valida ao sair do campo]
B -- always --> E[Valida em todo rebuild]
C --> F[Validação só ao chamar validate()]
D --> G[Mensagem aparece após o campo perder foco]
E --> H[Mensagem aparece mesmo em campos não tocados]
F --> I[Botão Enviar → validate() → percorre todos os campos]
G --> I
Módulo 06 — Formulários, Validação e Feedback ao Usuário
Você chegou a um dos módulos mais práticos de toda a disciplina. Nos módulos anteriores, você aprendeu a organizar o código Dart com solidez, a construir interfaces com widgets e layouts sofisticados, e a conectar telas com o go_router. Agora chegou o momento de aprender a coletar informações do usuário de forma correta — e isso é muito mais do que simplesmente colocar um TextField na tela. Um formulário bem construído valida os dados no momento certo, guia o usuário pelo preenchimento com o teclado certo em cada campo, fornece mensagens de erro que realmente explicam o problema, e confirma visualmente o sucesso ou o fracasso de cada ação. Estude este material com atenção antes da aula presencial, execute todos os exemplos no seu ambiente local e chegue ao encontro pronto para construir a tela de cadastro e o fluxo de login do Projeto Integrador.
O que Torna um Formulário Bem Construído
Antes de escrever qualquer linha de código, é preciso entender o que diferencia um formulário bem construído de um apenas funcional. Esta distinção tem consequências diretas tanto na experiência do usuário quanto na confiabilidade dos dados que chegam ao backend.
Pense na última vez que você preencheu um formulário em um aplicativo e sentiu que algo estava errado. Talvez o aplicativo só tenha informado o erro depois que você tocou em “Enviar”, quando poderia ter alertado assim que você saiu do campo com o e-mail inválido. Ou talvez a mensagem de erro fosse tão genérica que você não sabia o que exatamente precisava corrigir. Ou, ainda, o teclado numérico não abriu automaticamente para o campo de telefone, e você teve que trocá-lo manualmente. Esses são os detalhes que separam uma experiência frustrante de uma fluida.
Um formulário bem construído atende a quatro critérios simultaneamente. O primeiro é a correção estrutural: os dados coletados precisam satisfazer as restrições de formato e de negócio antes de serem enviados ao servidor — um e-mail malformado, um CPF com dígito verificador incorreto ou um campo obrigatório vazio jamais devem chegar à API. O segundo é a comunicação clara: quando um campo está inválido, o usuário precisa saber exatamente o que está errado e o que fazer para corrigir — mensagens como “campo inválido” são menos úteis do que “o e-mail deve ter o formato usuario@dominio.com”. O terceiro é o fluxo natural: o usuário não deve precisar tocar manualmente em cada campo em sequência — pressionar a tecla de retorno deve levar o cursor ao próximo campo automaticamente, reduzindo o esforço de interação. O quarto é o feedback imediato: quando o usuário submete o formulário e uma operação assíncrona começa — como o envio dos dados ao servidor —, a interface precisa indicar que está trabalhando, impedindo que o usuário toque no botão novamente por achar que nada aconteceu.
O Flutter oferece um conjunto de ferramentas preciso para atender a todos esses critérios. O widget Form, combinado com TextFormField e FormState, fornece a estrutura de validação coordenada. O FocusNode controla o fluxo de foco entre campos. O TextInputType e o TextInputAction configuram o comportamento do teclado. O TextInputFormatter aplica máscaras de entrada em tempo real. O loading_overlay indica o carregamento durante operações longas. E o SnackBar, AlertDialog e BottomSheet comunicam resultados e solicitam confirmações. Você vai aprender cada um deles ao longo deste módulo.
O Widget Form e o Mecanismo de Validação
O Form é o widget que organiza e coordena a validação de um conjunto de campos de entrada. Você pode pensar nele como um gerente que conhece todos os campos sob sua responsabilidade e sabe como acionar a validação de todos eles de uma vez, sem que você precise chamar a validação de cada campo individualmente.
A relação entre Form, TextFormField e FormState é o coração do sistema de formulários do Flutter. O Form é um widget de layout invisível — ele não desenha nada na tela por conta própria, apenas envolve os campos de entrada e mantém o estado interno deles. Cada TextFormField dentro do Form se registra automaticamente como um campo do formulário. O FormState é o objeto de estado do Form, acessível por meio de uma GlobalKey<FormState>, e é ele que expõe os métodos validate(), save() e reset().
O método validate() percorre todos os TextFormField registrados no formulário, chama a função validator de cada um e, se algum retornar uma string não nula, exibe essa string como mensagem de erro abaixo do campo correspondente. O retorno do próprio validate() é um bool: true se todos os validadores retornaram null (sem erro), false se ao menos um retornou uma string de erro. Esse design permite verificar com uma única chamada se o formulário inteiro está válido antes de prosseguir com a operação de envio.
A GlobalKey<FormState> é o mecanismo que conecta o código imperativo — “valide agora quando o usuário tocar em Enviar” — ao estado declarativo do Form. Ela deve ser criada no State do widget que contém o formulário, guardada como um campo, e passada como key para o widget Form. Ao chamar _formKey.currentState!.validate(), você acessa o FormState atual do formulário por meio da chave.
Exemplo — Estrutura básica de Form com GlobalKey e validação
import 'package:flutter/material.dart';
// TelaCadastro: formulário de cadastro de novo usuário.
// É um StatefulWidget porque precisa manter a GlobalKey<FormState>
// e eventuais estados de carregamento.
class TelaCadastro extends StatefulWidget {
const TelaCadastro({super.key});
@override
State<TelaCadastro> createState() => _TelaCadastroState();
}
class _TelaCadastroState extends State<TelaCadastro> {
// A GlobalKey<FormState> é a chave que conecta o código ao Form.
// É declarada no State (não no build) para que a mesma instância
// persista entre os rebuilds do widget.
final _formKey = GlobalKey<FormState>();
// Controllers para ler os valores dos campos depois da validação.
final _nomeController = TextEditingController();
final _emailController = TextEditingController();
@override
void dispose() {
// IMPORTANTE: sempre descarte os controllers no dispose para
// liberar os recursos de memória associados a eles.
_nomeController.dispose();
_emailController.dispose();
super.dispose();
}
// Método chamado quando o usuário toca em "Cadastrar".
void _submeterFormulario() {
// validate() chama o validator de cada TextFormField do Form.
// Retorna true se todos passaram, false se algum falhou.
final formularioValido = _formKey.currentState!.validate();
if (!formularioValido) {
// Validação falhou: as mensagens de erro já apareceram nos campos.
// Nenhuma ação adicional é necessária aqui.
return;
}
// Chegando aqui, todos os campos são válidos.
final nome = _nomeController.text;
final email = _emailController.text;
// Em produção, aqui você chamaria o serviço de cadastro.
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Cadastro de $nome ($email) enviado!')),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Cadastro')),
body: SingleChildScrollView(
padding: const EdgeInsets.all(24),
// Form envolve os campos e os coordena.
// A key conecta o Form à _formKey declarada acima.
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// TextFormField é um TextField com suporte nativo a validação
// dentro de um Form. O validator é chamado por _formKey.currentState!.validate().
TextFormField(
controller: _nomeController,
decoration: const InputDecoration(
labelText: 'Nome completo',
hintText: 'Ex: Ana Lima',
border: OutlineInputBorder(),
),
// validator: chamado quando validate() é acionado.
// Retorne null se o valor for válido.
// Retorne uma String com a mensagem de erro se for inválido.
validator: (valor) {
if (valor == null || valor.trim().isEmpty) {
return 'O nome é obrigatório';
}
if (valor.trim().length < 3) {
return 'O nome deve ter pelo menos 3 caracteres';
}
return null; // null = campo válido
},
),
const SizedBox(height: 16),
TextFormField(
controller: _emailController,
decoration: const InputDecoration(
labelText: 'E-mail',
hintText: 'Ex: ana@email.com',
border: OutlineInputBorder(),
),
keyboardType: TextInputType.emailAddress,
validator: (valor) {
if (valor == null || valor.trim().isEmpty) {
return 'O e-mail é obrigatório';
}
// Verificação básica de formato de e-mail com RegExp.
final regexEmail = RegExp(r'^[^@]+@[^@]+\.[^@]+$');
if (!regexEmail.hasMatch(valor.trim())) {
return 'Informe um e-mail válido';
}
return null;
},
),
const SizedBox(height: 24),
ElevatedButton(
onPressed: _submeterFormulario,
child: const Text('Cadastrar'),
),
],
),
),
),
);
}
}import 'package:flutter/material.dart';
class TelaCadastro extends StatefulWidget {
const TelaCadastro({super.key});
@override
State<TelaCadastro> createState() => _TelaCadastroState();
}
class _TelaCadastroState extends State<TelaCadastro> {
final _formKey = GlobalKey<FormState>();
final _nomeController = TextEditingController();
final _emailController = TextEditingController();
@override
void dispose() {
_nomeController.dispose();
_emailController.dispose();
super.dispose();
}
void _submeter() {
if (!_formKey.currentState!.validate()) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Cadastro de ${_nomeController.text} (${_emailController.text}) enviado!'),
),
);
}
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(title: const Text('Cadastro')),
body: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextFormField(
controller: _nomeController,
decoration: const InputDecoration(
labelText: 'Nome completo', border: OutlineInputBorder()),
validator: (v) => (v == null || v.trim().length < 3)
? 'Nome deve ter pelo menos 3 caracteres'
: null,
),
const SizedBox(height: 16),
TextFormField(
controller: _emailController,
decoration: const InputDecoration(
labelText: 'E-mail', border: OutlineInputBorder()),
keyboardType: TextInputType.emailAddress,
validator: (v) {
if (v == null || v.trim().isEmpty) return 'E-mail obrigatório';
return RegExp(r'^[^@]+@[^@]+\.[^@]+$').hasMatch(v.trim())
? null
: 'Informe um e-mail válido';
},
),
const SizedBox(height: 24),
ElevatedButton(onPressed: _submeter, child: const Text('Cadastrar')),
],
),
),
),
);
}Observe dois pontos técnicos importantes no exemplo acima. O primeiro é o SingleChildScrollView envolvendo o Form. Em dispositivos com telas menores, ou quando o teclado sobe e reduz o espaço disponível, um formulário com vários campos pode ultrapassar a altura visível da tela. Sem o SingleChildScrollView, os campos inferiores ficariam cortados ou o Flutter exibiria o erro de overflow amarelo e preto. O segundo ponto é a chamada a _nomeController.dispose() e _emailController.dispose() no método dispose() do State. Um TextEditingController aloca recursos internos de memória para monitorar as mudanças no campo de texto. Se ele não for descartado quando o widget é removido da árvore, esses recursos ficam retidos indefinidamente — um vazamento de memória clássico em Flutter.
O Ciclo de Validação: Quando e Como Validar
O momento em que a validação ocorre tem um impacto direto na experiência do usuário. Validar apenas no momento do envio pode frustrar quem digitou tudo e só descobre os erros ao final. Validar a cada tecla pressionada pode ser perturbador, exibindo mensagens de erro antes mesmo de o usuário terminar de digitar.
O Form do Flutter suporta três modos de auto-validação, configuráveis pela propriedade autovalidateMode. O modo AutovalidateMode.disabled (padrão) só ativa a validação quando validate() é chamado explicitamente — geralmente quando o usuário pressiona o botão de envio. O modo AutovalidateMode.onUserInteraction ativa a validação assim que o usuário começa a interagir com um campo específico — é o comportamento mais recomendado para formulários, pois exibe o erro logo após o usuário sair de um campo mal preenchido, sem exibi-lo prematuramente. O modo AutovalidateMode.always valida continuamente a cada rebuild — raramente desejável, pois exibe erros em campos que o usuário ainda não tocou, o que é perturbador.
A estratégia mais equilibrada é usar AutovalidateMode.onUserInteraction. Com ela, o campo de e-mail permanece sem mensagem de erro enquanto o usuário está digitando. Assim que o usuário sai do campo (move o foco para o próximo), o validador é chamado e, se o valor estiver incorreto, a mensagem aparece. Dessa forma, o usuário recebe feedback imediato sem ser interrompido durante a digitação.
Validadores Customizados para o Projeto Integrador
O sistema de validação do Flutter é extensível por design: qualquer função com assinatura String? Function(String?) pode ser usada como validador. Isso permite criar uma biblioteca de validadores reutilizáveis que encapsulam as regras de negócio do domínio da aplicação.
No Projeto Integrador, o formulário de cadastro exige validações específicas para cada tipo de dado: o nome deve ser não vazio e ter ao menos dois termos (nome e sobrenome), o e-mail deve ter formato válido, o telefone deve ter o formato brasileiro com DDD, a senha deve ter no mínimo oito caracteres e conter ao menos uma letra maiúscula e um número. Colocar toda essa lógica inline dentro de cada validator tornaria o código do widget longo e difícil de testar. A solução idiomática é extrair os validadores para funções estáticas em uma classe dedicada.
Exemplo — Classe de validadores reutilizáveis para o Projeto Integrador
// Classe que agrupa os validadores do projeto.
// Todos os métodos são estáticos: não é necessário instanciar a classe.
// Cada validador segue a assinatura String? Function(String?):
// - retorna null se o valor é válido;
// - retorna uma String com a mensagem de erro se é inválido.
class Validadores {
// Construtor privado: esta classe não deve ser instanciada.
Validadores._();
// Valida campo obrigatório genérico.
static String? obrigatorio(String? valor, {String campo = 'Este campo'}) {
if (valor == null || valor.trim().isEmpty) {
return '$campo é obrigatório';
}
return null;
}
// Valida nome completo: deve ter ao menos dois termos não vazios.
static String? nomeCompleto(String? valor) {
final obrigatorioErro = obrigatorio(valor, campo: 'O nome');
if (obrigatorioErro != null) return obrigatorioErro;
final partes = valor!.trim().split(' ').where((p) => p.isNotEmpty).toList();
if (partes.length < 2) {
return 'Informe nome e sobrenome';
}
return null;
}
// Valida formato de e-mail com expressão regular.
static String? email(String? valor) {
final obrigatorioErro = obrigatorio(valor, campo: 'O e-mail');
if (obrigatorioErro != null) return obrigatorioErro;
// Expressão regular para verificar o formato básico de e-mail.
// Não garante que o domínio existe, apenas que o formato está correto.
final regex = RegExp(r'^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$');
if (!regex.hasMatch(valor!.trim())) {
return 'Informe um e-mail válido (ex: usuario@email.com)';
}
return null;
}
// Valida telefone brasileiro: aceita (11) 91234-5678 ou 11912345678.
// Remove caracteres não numéricos antes de verificar o tamanho.
static String? telefone(String? valor) {
final obrigatorioErro = obrigatorio(valor, campo: 'O telefone');
if (obrigatorioErro != null) return obrigatorioErro;
final apenasDigitos = valor!.replaceAll(RegExp(r'\D'), '');
if (apenasDigitos.length < 10 || apenasDigitos.length > 11) {
return 'Informe um telefone válido com DDD';
}
return null;
}
// Valida senha: mínimo 8 caracteres, ao menos uma maiúscula e um número.
static String? senha(String? valor) {
final obrigatorioErro = obrigatorio(valor, campo: 'A senha');
if (obrigatorioErro != null) return obrigatorioErro;
if (valor!.length < 8) {
return 'A senha deve ter no mínimo 8 caracteres';
}
if (!valor.contains(RegExp(r'[A-Z]'))) {
return 'A senha deve conter ao menos uma letra maiúscula';
}
if (!valor.contains(RegExp(r'[0-9]'))) {
return 'A senha deve conter ao menos um número';
}
return null;
}
// Valida confirmação de senha: deve ser igual ao valor fornecido.
// Recebe a senha original como parâmetro — exemplo de validador com closure.
static String? Function(String?) confirmarSenha(String senhaOriginal) {
return (String? valor) {
final obrigatorioErro = obrigatorio(valor, campo: 'A confirmação de senha');
if (obrigatorioErro != null) return obrigatorioErro;
if (valor != senhaOriginal) {
return 'As senhas não coincidem';
}
return null;
};
}
}
// Uso nos TextFormField:
// validator: Validadores.email,
// validator: Validadores.confirmarSenha(_senhaController.text),class Validadores {
Validadores._();
static String? obrigatorio(String? v, {String campo = 'Este campo'}) =>
(v == null || v.trim().isEmpty) ? '$campo é obrigatório' : null;
static String? nomeCompleto(String? v) {
final err = obrigatorio(v, campo: 'O nome');
if (err != null) return err;
return v!.trim().split(' ').where((p) => p.isNotEmpty).length < 2
? 'Informe nome e sobrenome'
: null;
}
static String? email(String? v) {
final err = obrigatorio(v, campo: 'O e-mail');
if (err != null) return err;
return RegExp(r'^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$')
.hasMatch(v!.trim())
? null
: 'Informe um e-mail válido';
}
static String? telefone(String? v) {
final err = obrigatorio(v, campo: 'O telefone');
if (err != null) return err;
final d = v!.replaceAll(RegExp(r'\D'), '');
return (d.length < 10 || d.length > 11)
? 'Informe um telefone válido com DDD'
: null;
}
static String? senha(String? v) {
final err = obrigatorio(v, campo: 'A senha');
if (err != null) return err;
if (v!.length < 8) return 'Mínimo 8 caracteres';
if (!v.contains(RegExp(r'[A-Z]'))) return 'Inclua uma letra maiúscula';
if (!v.contains(RegExp(r'[0-9]'))) return 'Inclua um número';
return null;
}
static String? Function(String?) confirmarSenha(String original) =>
(v) => (v != original) ? 'As senhas não coincidem' : obrigatorio(v);
}O padrão de extrair validadores para uma classe estática tem uma vantagem adicional que você vai apreciar mais adiante na disciplina: esses validadores são funções Dart puras, sem dependência de widgets ou contexto, o que os torna trivialmente testáveis com testes unitários. Você pode escrever um teste para Validadores.email('nao-e-um-email') e verificar que ele retorna a mensagem correta sem precisar de um emulador ou de qualquer infraestrutura Flutter.
Gerenciamento de Foco com FocusNode
Em um formulário com vários campos, o comportamento do foco — qual campo está ativo e recebendo a entrada do teclado em cada momento — tem um impacto significativo na fluidez da experiência. Um formulário bem construído guia o usuário de campo em campo automaticamente, sem que ele precise tocar manualmente em cada um depois de preencher o anterior.
O FocusNode é o objeto que representa o “ponto de atenção” do teclado em um determinado campo. Cada TextFormField possui um FocusNode interno por padrão, mas você pode criar e fornecer explicitamente seus próprios FocusNodes para controlar o foco programaticamente. Ao chamar meuFocusNode.requestFocus(), você move o foco do teclado para aquele campo. Ao chamar meuFocusNode.unfocus(), você fecha o teclado e remove o foco de todos os campos.
A propriedade onFieldSubmitted do TextFormField é chamada quando o usuário pressiona a tecla de ação do teclado — que pode ser “Próximo”, “Enviar”, “Concluído” ou outras, configurável via textInputAction. O padrão natural é: ao pressionar “Próximo” no campo de nome, o foco move para o campo de e-mail; ao pressionar “Próximo” no campo de e-mail, o foco move para o campo de senha; ao pressionar “Enviar” no campo de senha, o formulário é submetido.
Assim como os TextEditingControllers, os FocusNodes alocam recursos internos e precisam ser descartados no dispose() do State. Esquecer de chamar meuFocusNode.dispose() é uma fonte comum de vazamentos de memória em aplicações Flutter com formulários.
Exemplo — Gerenciamento de foco entre campos do formulário de login
import 'package:flutter/material.dart';
class TelaLogin extends StatefulWidget {
const TelaLogin({super.key});
@override
State<TelaLogin> createState() => _TelaLoginState();
}
class _TelaLoginState extends State<TelaLogin> {
final _formKey = GlobalKey<FormState>();
final _emailController = TextEditingController();
final _senhaController = TextEditingController();
// FocusNodes: um para cada campo que precisa receber foco programaticamente.
// O campo de e-mail não precisa de FocusNode explícito aqui porque
// ele recebe o foco inicial automaticamente ao abrir a tela.
final _senhaFocus = FocusNode();
@override
void dispose() {
_emailController.dispose();
_senhaController.dispose();
// IMPORTANTE: descarte o FocusNode assim como os controllers.
_senhaFocus.dispose();
super.dispose();
}
void _submeter() {
if (!_formKey.currentState!.validate()) return;
// Lógica de login aqui...
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Entrar')),
body: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextFormField(
controller: _emailController,
decoration: const InputDecoration(
labelText: 'E-mail',
prefixIcon: Icon(Icons.email_outlined),
border: OutlineInputBorder(),
),
keyboardType: TextInputType.emailAddress,
// textInputAction: define o ícone e o comportamento da tecla
// de ação do teclado virtual. 'next' exibe "Próximo" ou "→".
textInputAction: TextInputAction.next,
// onFieldSubmitted: chamado quando o usuário pressiona a tecla de ação.
// Aqui, movemos o foco para o campo de senha.
onFieldSubmitted: (_) {
FocusScope.of(context).requestFocus(_senhaFocus);
},
validator: Validadores.email,
),
const SizedBox(height: 16),
TextFormField(
controller: _senhaController,
// Vincula o FocusNode ao campo de senha.
focusNode: _senhaFocus,
decoration: const InputDecoration(
labelText: 'Senha',
prefixIcon: Icon(Icons.lock_outline),
border: OutlineInputBorder(),
),
obscureText: true, // oculta os caracteres digitados
// 'done' exibe "Concluído" ou "✓" no teclado.
textInputAction: TextInputAction.done,
// Ao pressionar "Concluído" no campo de senha, submit o formulário.
onFieldSubmitted: (_) => _submeter(),
validator: Validadores.senha,
),
const SizedBox(height: 24),
ElevatedButton(
onPressed: _submeter,
child: const Text('Entrar'),
),
],
),
),
),
);
}
}import 'package:flutter/material.dart';
class TelaLogin extends StatefulWidget {
const TelaLogin({super.key});
@override
State<TelaLogin> createState() => _TelaLoginState();
}
class _TelaLoginState extends State<TelaLogin> {
final _formKey = GlobalKey<FormState>();
final _emailCtrl = TextEditingController();
final _senhaCtrl = TextEditingController();
final _senhaFocus = FocusNode();
@override
void dispose() {
_emailCtrl.dispose();
_senhaCtrl.dispose();
_senhaFocus.dispose();
super.dispose();
}
void _submeter() {
if (!_formKey.currentState!.validate()) return;
// Lógica de autenticação aqui.
}
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(title: const Text('Entrar')),
body: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextFormField(
controller: _emailCtrl,
decoration: const InputDecoration(
labelText: 'E-mail',
prefixIcon: Icon(Icons.email_outlined),
border: OutlineInputBorder()),
keyboardType: TextInputType.emailAddress,
textInputAction: TextInputAction.next,
onFieldSubmitted: (_) =>
FocusScope.of(context).requestFocus(_senhaFocus),
validator: Validadores.email,
),
const SizedBox(height: 16),
TextFormField(
controller: _senhaCtrl,
focusNode: _senhaFocus,
decoration: const InputDecoration(
labelText: 'Senha',
prefixIcon: Icon(Icons.lock_outline),
border: OutlineInputBorder()),
obscureText: true,
textInputAction: TextInputAction.done,
onFieldSubmitted: (_) => _submeter(),
validator: Validadores.senha,
),
const SizedBox(height: 24),
ElevatedButton(
onPressed: _submeter, child: const Text('Entrar')),
],
),
),
),
);
}Existe uma diferença sutil entre FocusScope.of(context).requestFocus(focusNode) e focusNode.requestFocus(). Ambas movem o foco para o nó especificado, mas a versão com FocusScope garante que o escopo de foco correto seja usado, o que é especialmente importante quando o formulário está dentro de modais ou dialogs com seus próprios escopos de foco. A versão com FocusScope.of(context) é mais robusta e é a abordagem recomendada.
TextInputType e TextInputAction: Configurando o Teclado
O teclado virtual de um dispositivo móvel pode se apresentar de várias formas: numérico, alfanumérico completo, com ou sem símbolo de arroba, com ou sem botão de vírgula decimal. Configurar o tipo correto de teclado para cada campo melhora significativamente a experiência do usuário, que não precisa trocar manualmente o tipo de entrada ao preencher dados.
A propriedade keyboardType do TextFormField aceita valores do tipo TextInputType. Os valores mais usados em aplicativos de pedidos são os seguintes. O TextInputType.text é o padrão — teclado alfanumérico completo, adequado para nome e endereço. O TextInputType.emailAddress ativa um layout que inclui o símbolo @ em posição de fácil acesso, ideal para campos de e-mail. O TextInputType.number exibe o teclado numérico puro, adequado para campos como quantidade de itens. O TextInputType.phone exibe um teclado numérico com símbolos de telefone como +, * e #, indicado para campos de telefone. O TextInputType.visiblePassword exibe o teclado completo sem sugestões de autocompletar, adequado para senhas que precisam ser digitadas caractere por caractere.
A propriedade textInputAction controla o ícone e o comportamento da tecla de ação no canto inferior direito do teclado. Os valores mais importantes são TextInputAction.next (exibe “Próximo” ou uma seta, indicado para campos intermediários do formulário), TextInputAction.done (exibe “Concluído” ou “OK”, indicado para o último campo antes de uma ação) e TextInputAction.search (exibe uma lupa, indicado para campos de busca).
TextInputFormatter: Máscaras de Entrada em Tempo Real
Máscaras de entrada transformam o texto conforme o usuário digita, inserindo automaticamente separadores, traços e parênteses no lugar certo. Um campo de telefone que formata automaticamente 11987654321 como (11) 98765-4321 enquanto o usuário digita é muito mais amigável do que um campo de texto livre onde o usuário precisa digitar a formatação manualmente.
O TextInputFormatter é uma classe abstrata do Flutter que intercepta cada mudança no valor de um TextFormField e pode transformar o texto antes que ele seja exibido e armazenado. Você pode encadear múltiplos formatadores na propriedade inputFormatters do TextFormField, que aceita uma lista. O FilteringTextInputFormatter é o formatador embutido mais útil: com FilteringTextInputFormatter.digitsOnly, você garante que o campo aceite apenas dígitos numéricos, sem que o usuário consiga digitar letras por acidente.
Para máscaras mais complexas — como a formatação de telefone (XX) XXXXX-XXXX ou de data DD/MM/AAAA — é necessário implementar um TextInputFormatter customizado. A implementação consiste em sobrescrever o método formatEditUpdate, que recebe o valor anterior e o novo valor do campo e retorna um TextEditingValue transformado.
Exemplo — Formatador de telefone e campo com máscara
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
// TelefoneFormatter: formata o número enquanto o usuário digita.
// Resultado: (11) 98765-4321 para celular ou (11) 8765-4321 para fixo.
class TelefoneFormatter extends TextInputFormatter {
@override
TextEditingValue formatEditUpdate(
TextEditingValue oldValue, // valor antes da edição
TextEditingValue newValue, // valor após a edição (antes da formatação)
) {
// Remove todos os caracteres não-numéricos do novo valor.
final digitos = newValue.text.replaceAll(RegExp(r'\D'), '');
// Limita a 11 dígitos (DDD + número de celular) ou 10 (DDD + fixo).
final limitado = digitos.length > 11 ? digitos.substring(0, 11) : digitos;
// Aplica a máscara progressivamente conforme os dígitos são inseridos.
final buffer = StringBuffer();
for (int i = 0; i < limitado.length; i++) {
if (i == 0) buffer.write('(');
if (i == 2) buffer.write(') ');
// Celular: 9 dígitos após o DDD → traço na posição 7
// Fixo: 8 dígitos após o DDD → traço na posição 6
if (limitado.length == 11 && i == 7) buffer.write('-');
if (limitado.length <= 10 && i == 6) buffer.write('-');
buffer.write(limitado[i]);
}
final textoFormatado = buffer.toString();
return TextEditingValue(
text: textoFormatado,
// O cursor fica sempre ao final do texto formatado.
selection: TextSelection.collapsed(offset: textoFormatado.length),
);
}
}
// Uso no TextFormField:
class CampoTelefone extends StatelessWidget {
final TextEditingController controller;
final String? Function(String?) validator;
const CampoTelefone({
super.key,
required this.controller,
required this.validator,
});
@override
Widget build(BuildContext context) {
return TextFormField(
controller: controller,
decoration: const InputDecoration(
labelText: 'Telefone',
hintText: '(11) 98765-4321',
prefixIcon: Icon(Icons.phone_outlined),
border: OutlineInputBorder(),
),
// Abre o teclado numérico com símbolos de telefone.
keyboardType: TextInputType.phone,
inputFormatters: [
// FilteringTextInputFormatter garante que só dígitos entrem no campo
// antes do TelefoneFormatter aplicar a máscara.
FilteringTextInputFormatter.digitsOnly,
TelefoneFormatter(),
],
validator: validator,
);
}
}import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
class TelefoneFormatter extends TextInputFormatter {
@override
TextEditingValue formatEditUpdate(
TextEditingValue _, TextEditingValue newValue) {
final d = newValue.text.replaceAll(RegExp(r'\D'), '');
final lim = d.length > 11 ? d.substring(0, 11) : d;
final buf = StringBuffer();
for (int i = 0; i < lim.length; i++) {
if (i == 0) buf.write('(');
if (i == 2) buf.write(') ');
if (lim.length == 11 && i == 7) buf.write('-');
if (lim.length <= 10 && i == 6) buf.write('-');
buf.write(lim[i]);
}
final t = buf.toString();
return TextEditingValue(
text: t, selection: TextSelection.collapsed(offset: t.length));
}
}
class CampoTelefone extends StatelessWidget {
final TextEditingController controller;
final String? Function(String?) validator;
const CampoTelefone(
{super.key, required this.controller, required this.validator});
@override
Widget build(BuildContext context) => TextFormField(
controller: controller,
decoration: const InputDecoration(
labelText: 'Telefone',
hintText: '(11) 98765-4321',
prefixIcon: Icon(Icons.phone_outlined),
border: OutlineInputBorder()),
keyboardType: TextInputType.phone,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
TelefoneFormatter(),
],
validator: validator,
);
}Uma observação importante sobre TextInputFormatter e validação: o formatador transforma a representação visual do valor — a string com parênteses e traços —, mas o que chega ao validator é exatamente essa string formatada. Portanto, ao validar um campo de telefone que usa TelefoneFormatter, o validator recebe "(11) 98765-4321" e não "11987654321". O código do validador precisa levar isso em conta. Uma estratégia simples é remover todos os caracteres não numéricos com valor.replaceAll(RegExp(r'\D'), '') antes de verificar o comprimento, exatamente como foi feito no validador Validadores.telefone mostrado anteriormente.
O Formulário de Cadastro Completo do Projeto Integrador
Com as peças individuais — Form, validadores, FocusNodes e formatadores — compreendidas separadamente, é hora de juntá-las em um formulário completo que representa um caso de uso real do Projeto Integrador: o cadastro de um novo usuário no aplicativo de pedidos.
O formulário de cadastro do aplicativo do professor coleta cinco informações: nome completo, e-mail, telefone, senha e confirmação de senha. Cada campo tem sua configuração específica de teclado, máscara, validador e fluxo de foco. O botão de cadastro chama validate() antes de prosseguir e exibe o indicador de carregamento enquanto a operação assíncrona de criação de conta está em andamento.
Exemplo — Formulário de cadastro completo com todos os recursos
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
class TelaCadastroCompleto extends StatefulWidget {
const TelaCadastroCompleto({super.key});
@override
State<TelaCadastroCompleto> createState() => _TelaCadastroCompletoState();
}
class _TelaCadastroCompletoState extends State<TelaCadastroCompleto> {
final _formKey = GlobalKey<FormState>();
// Controllers para os cinco campos do formulário.
final _nomeController = TextEditingController();
final _emailController = TextEditingController();
final _telefoneController = TextEditingController();
final _senhaController = TextEditingController();
final _confirmacaoController = TextEditingController();
// FocusNodes para os campos intermediários (o primeiro campo recebe
// foco automaticamente e o último submete o formulário).
final _emailFocus = FocusNode();
final _telefoneFocus = FocusNode();
final _senhaFocus = FocusNode();
final _confirmacaoFocus = FocusNode();
// Estado de carregamento: true enquanto o cadastro está sendo enviado.
bool _carregando = false;
// Estado de visibilidade das senhas: permite ao usuário ver o que digitou.
bool _senhaVisivel = false;
bool _confirmacaoVisivel = false;
@override
void dispose() {
_nomeController.dispose();
_emailController.dispose();
_telefoneController.dispose();
_senhaController.dispose();
_confirmacaoController.dispose();
_emailFocus.dispose();
_telefoneFocus.dispose();
_senhaFocus.dispose();
_confirmacaoFocus.dispose();
super.dispose();
}
Future<void> _submeter() async {
// 1. Valida todos os campos do formulário.
if (!_formKey.currentState!.validate()) return;
// 2. Fecha o teclado para melhorar a experiência durante o carregamento.
FocusScope.of(context).unfocus();
// 3. Ativa o indicador de carregamento.
setState(() => _carregando = true);
try {
// 4. Simula a chamada assíncrona ao serviço de cadastro.
// Em produção, aqui estaria: await _servicoCadastro.cadastrar(...)
await Future.delayed(const Duration(seconds: 2));
// 5. Exibe feedback de sucesso (somente se o widget ainda está montado).
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Cadastro realizado com sucesso!'),
backgroundColor: Colors.green,
),
);
}
} catch (e) {
// 6. Exibe feedback de erro ao usuário.
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Erro ao cadastrar: $e'),
backgroundColor: Colors.red,
),
);
}
} finally {
// 7. Desativa o indicador de carregamento independente do resultado.
if (mounted) {
setState(() => _carregando = false);
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Criar conta')),
body: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
child: Form(
key: _formKey,
autovalidateMode: AutovalidateMode.onUserInteraction,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Campo 1: Nome completo
TextFormField(
controller: _nomeController,
decoration: const InputDecoration(
labelText: 'Nome completo',
prefixIcon: Icon(Icons.person_outline),
border: OutlineInputBorder(),
),
textCapitalization: TextCapitalization.words,
textInputAction: TextInputAction.next,
onFieldSubmitted: (_) =>
FocusScope.of(context).requestFocus(_emailFocus),
validator: Validadores.nomeCompleto,
),
const SizedBox(height: 16),
// Campo 2: E-mail
TextFormField(
controller: _emailController,
focusNode: _emailFocus,
decoration: const InputDecoration(
labelText: 'E-mail',
prefixIcon: Icon(Icons.email_outlined),
border: OutlineInputBorder(),
),
keyboardType: TextInputType.emailAddress,
textInputAction: TextInputAction.next,
onFieldSubmitted: (_) =>
FocusScope.of(context).requestFocus(_telefoneFocus),
validator: Validadores.email,
),
const SizedBox(height: 16),
// Campo 3: Telefone com máscara
TextFormField(
controller: _telefoneController,
focusNode: _telefoneFocus,
decoration: const InputDecoration(
labelText: 'Telefone',
hintText: '(11) 98765-4321',
prefixIcon: Icon(Icons.phone_outlined),
border: OutlineInputBorder(),
),
keyboardType: TextInputType.phone,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
TelefoneFormatter(),
],
textInputAction: TextInputAction.next,
onFieldSubmitted: (_) =>
FocusScope.of(context).requestFocus(_senhaFocus),
validator: Validadores.telefone,
),
const SizedBox(height: 16),
// Campo 4: Senha com botão de visibilidade
TextFormField(
controller: _senhaController,
focusNode: _senhaFocus,
decoration: InputDecoration(
labelText: 'Senha',
prefixIcon: const Icon(Icons.lock_outline),
border: const OutlineInputBorder(),
// suffixIcon: botão para alternar a visibilidade da senha.
suffixIcon: IconButton(
icon: Icon(
_senhaVisivel ? Icons.visibility_off : Icons.visibility,
),
onPressed: () =>
setState(() => _senhaVisivel = !_senhaVisivel),
),
),
obscureText: !_senhaVisivel,
textInputAction: TextInputAction.next,
onFieldSubmitted: (_) =>
FocusScope.of(context).requestFocus(_confirmacaoFocus),
validator: Validadores.senha,
),
const SizedBox(height: 16),
// Campo 5: Confirmação de senha
TextFormField(
controller: _confirmacaoController,
focusNode: _confirmacaoFocus,
decoration: InputDecoration(
labelText: 'Confirmar senha',
prefixIcon: const Icon(Icons.lock_outline),
border: const OutlineInputBorder(),
suffixIcon: IconButton(
icon: Icon(
_confirmacaoVisivel
? Icons.visibility_off
: Icons.visibility,
),
onPressed: () => setState(
() => _confirmacaoVisivel = !_confirmacaoVisivel),
),
),
obscureText: !_confirmacaoVisivel,
textInputAction: TextInputAction.done,
onFieldSubmitted: (_) => _submeter(),
// validator usa a senha atual como referência para comparação.
validator: Validadores.confirmarSenha(_senhaController.text),
),
const SizedBox(height: 32),
// Botão de cadastro: desabilitado e com indicador durante o carregamento.
ElevatedButton(
onPressed: _carregando ? null : _submeter,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
),
child: _carregando
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Text('Criar conta', style: TextStyle(fontSize: 16)),
),
],
),
),
),
);
}
}import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
class TelaCadastroCompleto extends StatefulWidget {
const TelaCadastroCompleto({super.key});
@override
State<TelaCadastroCompleto> createState() => _State();
}
class _State extends State<TelaCadastroCompleto> {
final _formKey = GlobalKey<FormState>();
final _nomeCtrl = TextEditingController();
final _emailCtrl = TextEditingController();
final _telCtrl = TextEditingController();
final _senhaCtrl = TextEditingController();
final _confCtrl = TextEditingController();
final _emailF = FocusNode();
final _telF = FocusNode();
final _senhaF = FocusNode();
final _confF = FocusNode();
bool _loading = false, _showSenha = false, _showConf = false;
@override
void dispose() {
for (final c in [_nomeCtrl, _emailCtrl, _telCtrl, _senhaCtrl, _confCtrl]) {
c.dispose();
}
for (final f in [_emailF, _telF, _senhaF, _confF]) {
f.dispose();
}
super.dispose();
}
Future<void> _submeter() async {
if (!_formKey.currentState!.validate()) return;
FocusScope.of(context).unfocus();
setState(() => _loading = true);
try {
await Future.delayed(const Duration(seconds: 2));
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Conta criada!'), backgroundColor: Colors.green),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('$e'), backgroundColor: Colors.red),
);
}
} finally {
if (mounted) setState(() => _loading = false);
}
}
Widget _campo({
required TextEditingController ctrl,
required String label,
required IconData icon,
required String? Function(String?) validator,
FocusNode? focus,
FocusNode? nextFocus,
TextInputType tipo = TextInputType.text,
TextInputAction acao = TextInputAction.next,
List<TextInputFormatter> formatters = const [],
bool senha = false,
bool? senhaVisivel,
VoidCallback? toggleVisivel,
}) {
return TextFormField(
controller: ctrl,
focusNode: focus,
decoration: InputDecoration(
labelText: label,
prefixIcon: Icon(icon),
border: const OutlineInputBorder(),
suffixIcon: senha
? IconButton(
icon: Icon(
(senhaVisivel ?? false) ? Icons.visibility_off : Icons.visibility),
onPressed: toggleVisivel,
)
: null,
),
keyboardType: tipo,
textInputAction: acao,
inputFormatters: formatters,
obscureText: senha && !(senhaVisivel ?? false),
onFieldSubmitted: (_) => nextFocus != null
? FocusScope.of(context).requestFocus(nextFocus)
: _submeter(),
validator: validator,
);
}
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(title: const Text('Criar conta')),
body: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
child: Form(
key: _formKey,
autovalidateMode: AutovalidateMode.onUserInteraction,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_campo(ctrl: _nomeCtrl, label: 'Nome completo',
icon: Icons.person_outline, validator: Validadores.nomeCompleto,
nextFocus: _emailF,
textCapitalization: TextCapitalization.words),
const SizedBox(height: 16),
_campo(ctrl: _emailCtrl, label: 'E-mail',
icon: Icons.email_outlined, validator: Validadores.email,
focus: _emailF, nextFocus: _telF,
tipo: TextInputType.emailAddress),
const SizedBox(height: 16),
_campo(ctrl: _telCtrl, label: 'Telefone',
icon: Icons.phone_outlined, validator: Validadores.telefone,
focus: _telF, nextFocus: _senhaF,
tipo: TextInputType.phone,
formatters: [FilteringTextInputFormatter.digitsOnly, TelefoneFormatter()]),
const SizedBox(height: 16),
_campo(ctrl: _senhaCtrl, label: 'Senha',
icon: Icons.lock_outline, validator: Validadores.senha,
focus: _senhaF, nextFocus: _confF,
senha: true, senhaVisivel: _showSenha,
toggleVisivel: () => setState(() => _showSenha = !_showSenha)),
const SizedBox(height: 16),
_campo(ctrl: _confCtrl, label: 'Confirmar senha',
icon: Icons.lock_outline,
validator: Validadores.confirmarSenha(_senhaCtrl.text),
focus: _confF, acao: TextInputAction.done,
senha: true, senhaVisivel: _showConf,
toggleVisivel: () => setState(() => _showConf = !_showConf)),
const SizedBox(height: 32),
ElevatedButton(
onPressed: _loading ? null : _submeter,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16)),
child: _loading
? const SizedBox(
height: 20, width: 20,
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
: const Text('Criar conta', style: TextStyle(fontSize: 16)),
),
],
),
),
),
);
}Indicadores de Carregamento: O Pacote loading_overlay
O exemplo anterior usou um CircularProgressIndicator dentro do botão para indicar carregamento — uma abordagem eficaz para operações curtas. No entanto, quando uma operação pode levar vários segundos, ou quando você quer bloquear completamente a interação do usuário enquanto a operação está em andamento — evitando toques duplos em botões ou navegação acidental —, a solução mais robusta é um overlay de carregamento que cobre toda a tela.
O pacote loading_overlay: ^0.5.0 fornece exatamente isso. Ele envolve qualquer widget com uma camada semitransparente de bloqueio que aparece automaticamente quando um estado booleano é verdadeiro. A integração é simples: você envolve o Scaffold com o widget LoadingOverlay e passa o estado _carregando como isLoading.
Exemplo — Usando loading_overlay para bloquear a tela durante operação assíncrona
import 'package:flutter/material.dart';
import 'package:loading_overlay/loading_overlay.dart';
class TelaEditarPerfil extends StatefulWidget {
const TelaEditarPerfil({super.key});
@override
State<TelaEditarPerfil> createState() => _TelaEditarPerfilState();
}
class _TelaEditarPerfilState extends State<TelaEditarPerfil> {
final _formKey = GlobalKey<FormState>();
final _nomeController = TextEditingController(text: 'Ana Lima');
final _emailController = TextEditingController(text: 'ana.lima@email.com');
bool _carregando = false;
@override
void dispose() {
_nomeController.dispose();
_emailController.dispose();
super.dispose();
}
Future<void> _salvar() async {
if (!_formKey.currentState!.validate()) return;
FocusScope.of(context).unfocus();
setState(() => _carregando = true);
try {
// Simula a operação de salvamento no servidor.
await Future.delayed(const Duration(seconds: 3));
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Perfil atualizado com sucesso'),
backgroundColor: Colors.green,
),
);
}
} finally {
if (mounted) setState(() => _carregando = false);
}
}
@override
Widget build(BuildContext context) {
// LoadingOverlay envolve o Scaffold inteiro.
// Quando isLoading é true, ele exibe um overlay semitransparente
// com um CircularProgressIndicator no centro, bloqueando todos os toques.
return LoadingOverlay(
isLoading: _carregando,
// color: cor do overlay semitransparente.
color: Colors.black,
// opacity: nível de opacidade do overlay (0.0 = transparente, 1.0 = opaco).
opacity: 0.5,
// progressIndicator: customiza o indicador exibido.
// Por padrão, usa CircularProgressIndicator.
progressIndicator: const CircularProgressIndicator(color: Colors.white),
child: Scaffold(
appBar: AppBar(title: const Text('Editar Perfil')),
body: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextFormField(
controller: _nomeController,
decoration: const InputDecoration(
labelText: 'Nome',
border: OutlineInputBorder(),
),
validator: Validadores.nomeCompleto,
),
const SizedBox(height: 16),
TextFormField(
controller: _emailController,
decoration: const InputDecoration(
labelText: 'E-mail',
border: OutlineInputBorder(),
),
keyboardType: TextInputType.emailAddress,
validator: Validadores.email,
),
const SizedBox(height: 24),
ElevatedButton(
onPressed: _salvar,
child: const Text('Salvar alterações'),
),
],
),
),
),
),
);
}
}import 'package:flutter/material.dart';
import 'package:loading_overlay/loading_overlay.dart';
class TelaEditarPerfil extends StatefulWidget {
const TelaEditarPerfil({super.key});
@override
State<TelaEditarPerfil> createState() => _State();
}
class _State extends State<TelaEditarPerfil> {
final _formKey = GlobalKey<FormState>();
final _nomeCtrl = TextEditingController(text: 'Ana Lima');
final _emailCtrl = TextEditingController(text: 'ana.lima@email.com');
bool _loading = false;
@override
void dispose() {
_nomeCtrl.dispose();
_emailCtrl.dispose();
super.dispose();
}
Future<void> _salvar() async {
if (!_formKey.currentState!.validate()) return;
FocusScope.of(context).unfocus();
setState(() => _loading = true);
try {
await Future.delayed(const Duration(seconds: 3));
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Perfil atualizado'), backgroundColor: Colors.green),
);
}
} finally {
if (mounted) setState(() => _loading = false);
}
}
@override
Widget build(BuildContext context) => LoadingOverlay(
isLoading: _loading,
color: Colors.black,
opacity: 0.5,
child: Scaffold(
appBar: AppBar(title: const Text('Editar Perfil')),
body: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextFormField(
controller: _nomeCtrl,
decoration: const InputDecoration(
labelText: 'Nome', border: OutlineInputBorder()),
validator: Validadores.nomeCompleto),
const SizedBox(height: 16),
TextFormField(
controller: _emailCtrl,
decoration: const InputDecoration(
labelText: 'E-mail', border: OutlineInputBorder()),
keyboardType: TextInputType.emailAddress,
validator: Validadores.email),
const SizedBox(height: 24),
ElevatedButton(
onPressed: _salvar, child: const Text('Salvar')),
],
),
),
),
),
);
}Uma diferença fundamental entre usar o overlay e apenas desabilitar o botão: o overlay impede qualquer interação com toda a tela — incluindo tocar em campos de texto, rolar a lista ou tocar em outros elementos que possam existir fora do formulário. Isso é importante em formulários dentro de telas com navegação, onde um toque na BottomNavigationBar durante o salvamento poderia trocar de aba e causar comportamento inesperado.
SnackBar: Feedback Temporário e Contextual
O SnackBar é o mecanismo de feedback mais utilizado no Flutter para comunicar o resultado de uma operação: ele aparece na parte inferior da tela por alguns segundos e desaparece automaticamente, sem exigir interação do usuário para fechar. É a escolha certa para mensagens temporárias como confirmações de ações bem-sucedidas, avisos e erros não bloqueantes.
Para exibir um SnackBar, você usa ScaffoldMessenger.of(context).showSnackBar(snackBar). O ScaffoldMessenger é o gerenciador de mensagens do Scaffold mais próximo na árvore de widgets. Ele garante que os SnackBars sejam exibidos corretamente mesmo quando o widget que os origina foi removido da árvore — por exemplo, quando uma operação assíncrona termina e o widget já foi navegado para outra tela. É por isso que a verificação if (mounted) é necessária antes de chamar showSnackBar em callbacks assíncronos: ela garante que o widget ainda está na árvore antes de tentar acessar o contexto.
Exemplo — SnackBar com diferentes estilos para sucesso, erro e aviso
import 'package:flutter/material.dart';
// Classe auxiliar para centralizar a exibição de SnackBars.
// O padrão de criar uma classe estática para isso evita repetição
// de código e garante consistência visual em toda a aplicação.
class AppSnackBar {
AppSnackBar._();
// SnackBar de sucesso: fundo verde, ícone de check.
static void sucesso(BuildContext context, String mensagem) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
const Icon(Icons.check_circle_outline, color: Colors.white),
const SizedBox(width: 12),
Expanded(child: Text(mensagem)),
],
),
backgroundColor: Colors.green.shade700,
// duration: quanto tempo o SnackBar permanece visível.
duration: const Duration(seconds: 3),
// behavior: floating faz o SnackBar flutuar sobre o conteúdo.
// fixed (padrão) faz o SnackBar empurrar o conteúdo para cima.
behavior: SnackBarBehavior.floating,
// shape: bordas arredondadas para o estilo Material 3.
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
);
}
// SnackBar de erro: fundo vermelho, ícone de erro.
static void erro(BuildContext context, String mensagem) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
const Icon(Icons.error_outline, color: Colors.white),
const SizedBox(width: 12),
Expanded(child: Text(mensagem)),
],
),
backgroundColor: Colors.red.shade700,
duration: const Duration(seconds: 4),
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
// action: botão opcional no SnackBar para ações como "Tentar novamente".
action: SnackBarAction(
label: 'OK',
textColor: Colors.white,
onPressed: () {
ScaffoldMessenger.of(context).hideCurrentSnackBar();
},
),
),
);
}
// SnackBar de aviso: fundo âmbar, ícone de atenção.
static void aviso(BuildContext context, String mensagem) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
const Icon(Icons.warning_amber_outlined, color: Colors.black87),
const SizedBox(width: 12),
Expanded(
child: Text(mensagem, style: const TextStyle(color: Colors.black87)),
),
],
),
backgroundColor: Colors.amber.shade400,
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
);
}
}import 'package:flutter/material.dart';
class AppSnackBar {
AppSnackBar._();
static void _show(BuildContext ctx, String msg, Color bg, IconData icon,
{SnackBarAction? action, Color textColor = Colors.white}) {
ScaffoldMessenger.of(ctx).showSnackBar(
SnackBar(
content: Row(children: [
Icon(icon, color: textColor),
const SizedBox(width: 12),
Expanded(child: Text(msg, style: TextStyle(color: textColor))),
]),
backgroundColor: bg,
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
action: action,
),
);
}
static void sucesso(BuildContext ctx, String msg) =>
_show(ctx, msg, Colors.green.shade700, Icons.check_circle_outline);
static void erro(BuildContext ctx, String msg) => _show(
ctx, msg, Colors.red.shade700, Icons.error_outline,
action: SnackBarAction(
label: 'OK',
textColor: Colors.white,
onPressed: () => ScaffoldMessenger.of(ctx).hideCurrentSnackBar(),
),
);
static void aviso(BuildContext ctx, String msg) => _show(
ctx, msg, Colors.amber.shade400, Icons.warning_amber_outlined,
textColor: Colors.black87,
);
}AlertDialog: Confirmações e Mensagens Bloqueantes
Enquanto o SnackBar é adequado para feedback não bloqueante, há situações em que você precisa que o usuário leia uma informação ou tome uma decisão explícita antes de continuar. O AlertDialog serve a esse propósito: ele aparece sobre o conteúdo atual, bloqueia a interação com o restante da tela e espera que o usuário escolha uma opção.
O AlertDialog é exibido com a função showDialog, que retorna um Future<T?>. O tipo T é o valor que a dialog retorna quando fechada — geralmente bool para diálogos de confirmação, onde true significa “o usuário confirmou” e false ou null significa “o usuário cancelou ou fechou a dialog”. A função Navigator.of(context).pop(valor) fecha a dialog e passa o valor para o Future.
No contexto do Projeto Integrador, um diálogo de confirmação é necessário antes de ações irreversíveis, como cancelar um pedido em andamento ou excluir uma conta. O usuário precisa confirmar explicitamente que entende as consequências antes de a ação ser executada.
Exemplo — AlertDialog de confirmação para cancelar pedido
import 'package:flutter/material.dart';
class TelaDetalhePedido extends StatefulWidget {
final String idPedido;
const TelaDetalhePedido({super.key, required this.idPedido});
@override
State<TelaDetalhePedido> createState() => _TelaDetalhePedidoState();
}
class _TelaDetalhePedidoState extends State<TelaDetalhePedido> {
bool _cancelando = false;
// Exibe o diálogo de confirmação e aguarda a resposta do usuário.
// Retorna true se o usuário confirmou, false se cancelou.
Future<bool> _confirmarCancelamento() async {
// showDialog retorna um Future<bool?>.
// O valor é o que foi passado para Navigator.of(context).pop().
final confirmado = await showDialog<bool>(
context: context,
// barrierDismissible: false impede fechar tocando fora do diálogo.
// Útil quando a ação é irreversível e o usuário precisa fazer uma escolha.
barrierDismissible: false,
builder: (BuildContext dialogContext) {
return AlertDialog(
title: const Text('Cancelar pedido'),
content: const Text(
'Tem certeza que deseja cancelar este pedido? '
'Esta ação não pode ser desfeita.',
),
actions: [
// Botão secundário: fecha o diálogo sem confirmar.
TextButton(
onPressed: () {
// pop(false): retorna false para o showDialog.
Navigator.of(dialogContext).pop(false);
},
child: const Text('Manter pedido'),
),
// Botão de confirmação destrutiva: vermelho para indicar perigo.
TextButton(
onPressed: () {
// pop(true): retorna true para o showDialog.
Navigator.of(dialogContext).pop(true);
},
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: const Text('Cancelar pedido'),
),
],
);
},
);
// O usuário pode ter fechado o dialog tocando fora (se barrierDismissible
// fosse true). Nesse caso, confirmado seria null. O ?? false garante
// que tratamos null como "não confirmou".
return confirmado ?? false;
}
Future<void> _cancelarPedido() async {
final confirmado = await _confirmarCancelamento();
if (!confirmado) return; // Usuário não confirmou: nada a fazer.
if (!mounted) return;
setState(() => _cancelando = true);
try {
await Future.delayed(const Duration(seconds: 2)); // Simula a API.
if (mounted) {
AppSnackBar.sucesso(context, 'Pedido cancelado com sucesso');
}
} finally {
if (mounted) setState(() => _cancelando = false);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Pedido ${widget.idPedido}')),
body: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('Detalhes do pedido ${widget.idPedido}'),
const SizedBox(height: 24),
OutlinedButton(
onPressed: _cancelando ? null : _cancelarPedido,
style: OutlinedButton.styleFrom(foregroundColor: Colors.red),
child: _cancelando
? const SizedBox(
height: 16,
width: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Cancelar pedido'),
),
],
),
),
);
}
}import 'package:flutter/material.dart';
class TelaDetalhePedido extends StatefulWidget {
final String idPedido;
const TelaDetalhePedido({super.key, required this.idPedido});
@override
State<TelaDetalhePedido> createState() => _State();
}
class _State extends State<TelaDetalhePedido> {
bool _cancelando = false;
Future<bool> _confirmar() async {
final ok = await showDialog<bool>(
context: context,
barrierDismissible: false,
builder: (ctx) => AlertDialog(
title: const Text('Cancelar pedido'),
content: const Text(
'Tem certeza? Esta ação não pode ser desfeita.'),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('Manter')),
TextButton(
onPressed: () => Navigator.of(ctx).pop(true),
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: const Text('Cancelar pedido')),
],
),
);
return ok ?? false;
}
Future<void> _cancelar() async {
if (!await _confirmar() || !mounted) return;
setState(() => _cancelando = true);
try {
await Future.delayed(const Duration(seconds: 2));
if (mounted) AppSnackBar.sucesso(context, 'Pedido cancelado');
} finally {
if (mounted) setState(() => _cancelando = false);
}
}
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(title: Text('Pedido ${widget.idPedido}')),
body: Center(
child: OutlinedButton(
onPressed: _cancelando ? null : _cancelar,
style: OutlinedButton.styleFrom(foregroundColor: Colors.red),
child: _cancelando
? const SizedBox(
height: 16, width: 16,
child: CircularProgressIndicator(strokeWidth: 2))
: const Text('Cancelar pedido'),
),
),
);
}Existe uma armadilha sutil relacionada ao context em diálogos assíncronos que vale mencionar explicitamente. O showDialog retorna um Future, e quando esse Future completa, o widget que chamou showDialog pode não estar mais montado — por exemplo, se o usuário navegou para outra tela enquanto o diálogo estava aberto (embora isso seja impedido pelo barrierDismissible: false no exemplo acima). A boa prática é sempre verificar if (mounted) após qualquer await que não garanta que o widget continua ativo.
BottomSheet: Painéis Deslizantes de Informação e Seleção
O BottomSheet é um painel que desliza a partir da borda inferior da tela. Ele é adequado para ações contextuais que se relacionam com o conteúdo da tela atual — como opções de edição para um item específico, filtros para uma lista ou detalhes expandidos de um elemento. Diferentemente do AlertDialog, o BottomSheet permite que o usuário continue vendo o conteúdo principal atrás dele.
A função showModalBottomSheet exibe um painel modal que bloqueia a interação com o conteúdo atrás — o usuário precisa fechar o painel antes de continuar usando a tela. É a variante mais comum em fluxos de seleção, como escolher a forma de pagamento ou selecionar um endereço de entrega. A função showBottomSheet (sem Modal) exibe um painel não modal, que coexiste com a tela sem bloqueá-la.
No Projeto Integrador, o showModalBottomSheet é a escolha natural para o painel de seleção de endereço de entrega: quando o usuário toca em “Alterar endereço” na tela de confirmação de pedido, um painel desliza com a lista de endereços cadastrados, e ao selecionar um deles o painel fecha e a tela de pedido é atualizada.
Exemplo — BottomSheet de seleção de endereço de entrega
import 'package:flutter/material.dart';
// Modelo simples de endereço para o exemplo.
class Endereco {
final String id;
final String logradouro;
final String complemento;
const Endereco({
required this.id,
required this.logradouro,
this.complemento = '',
});
}
class TelaConfirmarPedido extends StatefulWidget {
const TelaConfirmarPedido({super.key});
@override
State<TelaConfirmarPedido> createState() => _TelaConfirmarPedidoState();
}
class _TelaConfirmarPedidoState extends State<TelaConfirmarPedido> {
// Lista fictícia de endereços cadastrados.
final _enderecos = [
const Endereco(id: 'e1', logradouro: 'Rua das Flores, 123', complemento: 'Apto 42'),
const Endereco(id: 'e2', logradouro: 'Av. Central, 456'),
const Endereco(id: 'e3', logradouro: 'Rua do Comércio, 789', complemento: 'Sala 3'),
];
// Endereço atualmente selecionado.
late Endereco _enderecoSelecionado;
@override
void initState() {
super.initState();
_enderecoSelecionado = _enderecos.first;
}
// Exibe o BottomSheet de seleção de endereço.
// showModalBottomSheet retorna um Future<Endereco?>.
Future<void> _selecionarEndereco() async {
final selecionado = await showModalBottomSheet<Endereco>(
context: context,
// isScrollControlled: true permite que o BottomSheet ocupe mais
// do que 50% da altura da tela quando o conteúdo é maior.
isScrollControlled: true,
// shape: define o visual arredondado no topo do painel.
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (BuildContext sheetContext) {
return Padding(
// viewInsets.bottom: garante que o painel não fique atrás do teclado.
padding: EdgeInsets.only(
bottom: MediaQuery.of(sheetContext).viewInsets.bottom,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Alça visual do painel (padrão de design Material).
Container(
margin: const EdgeInsets.only(top: 12, bottom: 8),
width: 40,
height: 4,
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(2),
),
),
const Padding(
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Align(
alignment: Alignment.centerLeft,
child: Text(
'Selecionar endereço de entrega',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
),
),
const Divider(),
// Lista de endereços disponíveis.
..._enderecos.map((endereco) {
final selecionado = endereco.id == _enderecoSelecionado.id;
return ListTile(
leading: Icon(
selecionado ? Icons.radio_button_checked : Icons.radio_button_off,
color: selecionado ? Theme.of(context).colorScheme.primary : null,
),
title: Text(endereco.logradouro),
subtitle: endereco.complemento.isNotEmpty
? Text(endereco.complemento)
: null,
// Ao tocar em um endereço, fecha o BottomSheet passando
// o endereço selecionado como valor de retorno.
onTap: () => Navigator.of(sheetContext).pop(endereco),
);
}),
const SizedBox(height: 16),
],
),
);
},
);
// selecionado é null se o usuário fechou o painel sem escolher.
if (selecionado != null) {
setState(() => _enderecoSelecionado = selecionado);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Confirmar Pedido')),
body: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Endereço de entrega',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
const SizedBox(height: 8),
// Card mostrando o endereço selecionado com botão de alteração.
Card(
child: ListTile(
leading: const Icon(Icons.location_on_outlined),
title: Text(_enderecoSelecionado.logradouro),
subtitle: _enderecoSelecionado.complemento.isNotEmpty
? Text(_enderecoSelecionado.complemento)
: null,
trailing: TextButton(
onPressed: _selecionarEndereco,
child: const Text('Alterar'),
),
),
),
const Spacer(),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: () {},
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16)),
child: const Text('Confirmar pedido',
style: TextStyle(fontSize: 16)),
),
),
],
),
),
);
}
}import 'package:flutter/material.dart';
class Endereco {
final String id, logradouro, complemento;
const Endereco({required this.id, required this.logradouro, this.complemento = ''});
}
class TelaConfirmarPedido extends StatefulWidget {
const TelaConfirmarPedido({super.key});
@override
State<TelaConfirmarPedido> createState() => _State();
}
class _State extends State<TelaConfirmarPedido> {
final _enderecos = const [
Endereco(id: 'e1', logradouro: 'Rua das Flores, 123', complemento: 'Apto 42'),
Endereco(id: 'e2', logradouro: 'Av. Central, 456'),
Endereco(id: 'e3', logradouro: 'Rua do Comércio, 789', complemento: 'Sala 3'),
];
late Endereco _selected;
@override
void initState() {
super.initState();
_selected = _enderecos.first;
}
Future<void> _selecionar() async {
final result = await showModalBottomSheet<Endereco>(
context: context,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20))),
builder: (ctx) => Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
margin: const EdgeInsets.symmetric(vertical: 12),
width: 40, height: 4,
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(2))),
const Padding(
padding: EdgeInsets.fromLTRB(16, 0, 16, 8),
child: Align(
alignment: Alignment.centerLeft,
child: Text('Endereço de entrega',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
),
),
const Divider(),
..._enderecos.map((e) => ListTile(
leading: Icon(
e.id == _selected.id ? Icons.radio_button_checked : Icons.radio_button_off,
color: e.id == _selected.id ? Theme.of(ctx).colorScheme.primary : null,
),
title: Text(e.logradouro),
subtitle: e.complemento.isNotEmpty ? Text(e.complemento) : null,
onTap: () => Navigator.of(ctx).pop(e),
)),
const SizedBox(height: 16),
],
),
);
if (result != null) setState(() => _selected = result);
}
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(title: const Text('Confirmar Pedido')),
body: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Endereço de entrega',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
const SizedBox(height: 8),
Card(
child: ListTile(
leading: const Icon(Icons.location_on_outlined),
title: Text(_selected.logradouro),
subtitle: _selected.complemento.isNotEmpty
? Text(_selected.complemento) : null,
trailing: TextButton(
onPressed: _selecionar, child: const Text('Alterar')),
),
),
const Spacer(),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: () {},
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16)),
child: const Text('Confirmar pedido',
style: TextStyle(fontSize: 16)),
),
),
],
),
),
);
}Decoração de Campos com InputDecoration
A aparência de um campo de entrada em Flutter é completamente controlada pela InputDecoration. Ela é responsável por tudo que é visível ao redor do campo: o rótulo flutuante, o texto de dica, os ícones de prefixo e sufixo, a borda, o texto de erro e o texto de ajuda exibido abaixo do campo. Conhecer as propriedades mais importantes da InputDecoration permite criar formulários visualmente consistentes e informativos.
A InputDecoration aceita um conjunto rico de propriedades. O labelText é o rótulo que flutua para cima quando o campo recebe foco — ao contrário do hintText, que é um texto de exemplo que desaparece quando o usuário começa a digitar. O prefixIcon adiciona um ícone à esquerda do campo, útil para reforçar o tipo de informação esperada. O suffixIcon adiciona um elemento interativo à direita, como o botão de visibilidade em campos de senha. O helperText exibe uma instrução permanente abaixo do campo, como “Mínimo 8 caracteres”, que permanece visível mesmo durante a digitação. O errorText é normalmente gerenciado automaticamente pelo Form, mas pode ser definido manualmente para erros vindos do servidor.
A definição de uma InputDecoration padrão para toda a aplicação é feita no tema do MaterialApp. Ao definir inputDecorationTheme no ThemeData, você garante que todos os campos do aplicativo tenham a mesma aparência sem precisar repetir as configurações em cada TextFormField.
Exemplo — Tema global de InputDecoration e uso consistente
import 'package:flutter/material.dart';
// Configuração do tema global de campos de entrada.
// Esta configuração é feita no ThemeData do MaterialApp.
ThemeData _construirTema() {
final baseTheme = ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(seedColor: Colors.orange),
);
return baseTheme.copyWith(
inputDecorationTheme: InputDecorationTheme(
// border: borda padrão dos campos. OutlineInputBorder adiciona
// uma borda retangular com cantos arredondados ao redor do campo.
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
// enabledBorder: borda quando o campo está habilitado mas sem foco.
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.grey.shade300),
),
// focusedBorder: borda quando o campo tem foco.
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: baseTheme.colorScheme.primary,
width: 2,
),
),
// errorBorder: borda quando o campo tem erro.
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: baseTheme.colorScheme.error),
),
// filled: preenche o interior do campo com uma cor de fundo.
filled: true,
fillColor: Colors.grey.shade50,
// contentPadding: espaçamento interno do campo.
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
),
);
}
// Com o tema global configurado, os TextFormFields ficam mais simples:
// não é mais necessário especificar border: OutlineInputBorder() em cada um.
class CampoComTemaGlobal extends StatelessWidget {
const CampoComTemaGlobal({super.key});
@override
Widget build(BuildContext context) {
return TextFormField(
decoration: const InputDecoration(
labelText: 'E-mail',
// prefixIcon ainda precisa ser especificado por campo, pois é
// específico do contexto de cada campo.
prefixIcon: Icon(Icons.email_outlined),
hintText: 'usuario@email.com',
helperText: 'Será usado para o login e notificações',
// A borda vem do tema global — não precisa ser repetida aqui.
),
);
}
}import 'package:flutter/material.dart';
ThemeData construirTema() => ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(seedColor: Colors.orange),
).copyWith(
inputDecorationTheme: InputDecorationTheme(
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.grey.shade300),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: Colors.orange, width: 2),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: Colors.red),
),
filled: true,
fillColor: Colors.grey.shade50,
contentPadding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
),
);Boas Práticas de Experiência do Usuário em Formulários Móveis
A qualidade técnica de um formulário — validação correta, fluxo de foco adequado, teclado certo para cada campo — é a fundação. Mas há um conjunto de boas práticas de experiência do usuário que transformam um formulário tecnicamente correto em um formulário genuinamente bom de usar.
A primeira boa prática é validar com contexto. A mensagem de erro precisa ser específica o suficiente para o usuário entender o que está errado sem frustração. “Campo inválido” é quase inútil. “O e-mail deve ter o formato usuario@dominio.com” é acionável. “A senha deve ter pelo menos 8 caracteres, incluindo uma letra maiúscula e um número” explica exatamente o que o usuário precisa fazer para corrigir. Quanto mais específica a mensagem, menos tentativas o usuário precisa para corrigir o erro.
A segunda boa prática é preservar os dados após erro. Quando um formulário retorna um erro de servidor — como “este e-mail já está cadastrado” —, o usuário não deve perder o que digitou nos outros campos. O formulário deve manter todos os valores e apenas indicar qual campo precisa ser alterado. Limpar o formulário inteiro após um erro é uma das experiências mais frustrantes que um formulário pode oferecer.
A terceira boa prática é desabilitar o botão durante o carregamento. Um botão clicável enquanto uma operação está em andamento convida o usuário a clicar novamente, o que pode disparar a mesma operação duas vezes. Definir onPressed: null durante o carregamento — ou usar o overlay do loading_overlay — previne esse problema.
A quarta boa prática é organizar os campos em ordem lógica. Um formulário de cadastro deve seguir a sequência que o usuário espera: nome → e-mail → telefone → senha → confirmação. Não coloque a confirmação de senha antes da senha, ou o telefone no meio dos dados de acesso. A ordem dos campos deve refletir uma narrativa lógica: primeiro os dados de identificação, depois os dados de acesso.
A quinta boa prática é dar ao usuário controle sobre a senha digitada. O botão de alternância de visibilidade — que troca entre obscureText: true e obscureText: false — permite ao usuário verificar se digitou a senha corretamente antes de submeter. Isso é especialmente importante em redes móveis onde o autocorrect pode substituir caracteres inesperadamente.
flowchart LR
A[Usuário preenche campo] --> B{Campo válido?}
B -- Sim --> C[Sem mensagem de erro]
B -- Não --> D[Mensagem específica e acionável]
D --> E[Usuário corrige]
E --> B
C --> F{Todos os campos válidos?}
F -- Sim --> G[Botão habilitado]
F -- Não --> H[Botão habilitado mas validate() falha ao tocar]
G --> I[Usuário toca em Enviar]
I --> J[Loading overlay ativo]
J --> K{Operação bem-sucedida?}
K -- Sim --> L[SnackBar de sucesso]
K -- Não --> M[SnackBar de erro + dados preservados]
Validação Assíncrona: Verificando Dados no Servidor
Algumas validações não podem ser feitas localmente porque dependem de informações que só o servidor possui. O caso mais comum é verificar se um e-mail já está cadastrado no sistema antes de criar uma conta. O Form do Flutter suporta apenas validadores síncronos — a função validator não pode ser async. Para validações assíncronas, é necessário um padrão diferente.
A abordagem correta para validação assíncrona envolve duas etapas distintas. A primeira etapa é a validação síncrona normal: o botão chama validate() e verifica formatos e regras locais. Se a validação local passa, a segunda etapa começa: a submissão assíncrona chama o servidor e, se o servidor retornar um erro de negócio — como “e-mail já cadastrado” —, o código atualiza um campo de estado com a mensagem de erro e chama _formKey.currentState!.validate() novamente para que o campo exiba a mensagem. Para isso funcionar, o validador do campo precisa verificar o estado do erro do servidor.
Exemplo — Padrão para validação assíncrona de e-mail duplicado
import 'package:flutter/material.dart';
class TelaCadastroComValidacaoAsync extends StatefulWidget {
const TelaCadastroComValidacaoAsync({super.key});
@override
State<TelaCadastroComValidacaoAsync> createState() =>
_TelaCadastroComValidacaoAsyncState();
}
class _TelaCadastroComValidacaoAsyncState
extends State<TelaCadastroComValidacaoAsync> {
final _formKey = GlobalKey<FormState>();
final _emailController = TextEditingController();
final _senhaController = TextEditingController();
bool _carregando = false;
// Armazena o erro vindo do servidor para o campo de e-mail.
// null significa "sem erro de servidor" (o validador usa apenas as regras locais).
String? _errroEmailServidor;
@override
void dispose() {
_emailController.dispose();
_senhaController.dispose();
super.dispose();
}
Future<void> _submeter() async {
// Limpa o erro de servidor anterior antes de uma nova tentativa.
setState(() => _errroEmailServidor = null);
// Validação local: formato de e-mail, senha etc.
if (!_formKey.currentState!.validate()) return;
FocusScope.of(context).unfocus();
setState(() => _carregando = true);
try {
// Simula a chamada ao servidor de cadastro.
// Em produção: await _servico.cadastrar(email, senha)
await Future.delayed(const Duration(seconds: 2));
// Simula uma resposta de erro do servidor: e-mail já cadastrado.
// Em produção, isso viria da exceção ou do código de status da API.
const emailJaCadastrado = true; // Apenas para demonstração.
if (emailJaCadastrado) {
// Armazena a mensagem de erro de servidor no campo de estado.
setState(() {
_errroEmailServidor = 'Este e-mail já está cadastrado';
});
// Chama validate() novamente: agora o validador do e-mail vai
// encontrar _errroEmailServidor != null e exibir a mensagem.
_formKey.currentState!.validate();
return;
}
if (mounted) {
AppSnackBar.sucesso(context, 'Conta criada com sucesso!');
}
} finally {
if (mounted) setState(() => _carregando = false);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Criar conta')),
body: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextFormField(
controller: _emailController,
decoration: const InputDecoration(
labelText: 'E-mail',
border: OutlineInputBorder(),
),
keyboardType: TextInputType.emailAddress,
validator: (valor) {
// 1. Verifica primeiro se há erro vindo do servidor.
// Isso tem prioridade sobre a validação local.
if (_errroEmailServidor != null) return _errroEmailServidor;
// 2. Validação local de formato.
return Validadores.email(valor);
},
),
const SizedBox(height: 16),
TextFormField(
controller: _senhaController,
decoration: const InputDecoration(
labelText: 'Senha',
border: OutlineInputBorder(),
),
obscureText: true,
validator: Validadores.senha,
),
const SizedBox(height: 24),
ElevatedButton(
onPressed: _carregando ? null : _submeter,
child: _carregando
? const SizedBox(
height: 20, width: 20,
child: CircularProgressIndicator(
strokeWidth: 2, color: Colors.white))
: const Text('Criar conta'),
),
],
),
),
),
);
}
}import 'package:flutter/material.dart';
class TelaCadastroAsync extends StatefulWidget {
const TelaCadastroAsync({super.key});
@override
State<TelaCadastroAsync> createState() => _State();
}
class _State extends State<TelaCadastroAsync> {
final _formKey = GlobalKey<FormState>();
final _emailCtrl = TextEditingController();
final _senhaCtrl = TextEditingController();
bool _loading = false;
String? _emailErrServer;
@override
void dispose() {
_emailCtrl.dispose();
_senhaCtrl.dispose();
super.dispose();
}
Future<void> _submeter() async {
setState(() => _emailErrServer = null);
if (!_formKey.currentState!.validate()) return;
FocusScope.of(context).unfocus();
setState(() => _loading = true);
try {
await Future.delayed(const Duration(seconds: 2));
const jaExiste = true; // Simulação.
if (jaExiste) {
setState(() => _emailErrServer = 'E-mail já cadastrado');
_formKey.currentState!.validate();
return;
}
if (mounted) AppSnackBar.sucesso(context, 'Conta criada!');
} finally {
if (mounted) setState(() => _loading = false);
}
}
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(title: const Text('Criar conta')),
body: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextFormField(
controller: _emailCtrl,
decoration: const InputDecoration(
labelText: 'E-mail', border: OutlineInputBorder()),
keyboardType: TextInputType.emailAddress,
validator: (v) => _emailErrServer ?? Validadores.email(v),
),
const SizedBox(height: 16),
TextFormField(
controller: _senhaCtrl,
decoration: const InputDecoration(
labelText: 'Senha', border: OutlineInputBorder()),
obscureText: true,
validator: Validadores.senha,
),
const SizedBox(height: 24),
ElevatedButton(
onPressed: _loading ? null : _submeter,
child: _loading
? const SizedBox(
height: 20, width: 20,
child: CircularProgressIndicator(
strokeWidth: 2, color: Colors.white))
: const Text('Criar conta'),
),
],
),
),
),
);
}Erros Comuns e Como Evitá-los
Há um conjunto de erros que aparecem repetidamente em implementações de formulários Flutter, independentemente do nível de experiência do desenvolvedor. Conhecê-los antecipadamente poupa horas de depuração.
O primeiro erro é esquecer o dispose() dos controllers e FocusNodes. Isso causa vazamentos de memória que se acumulam ao longo do tempo de uso do aplicativo e podem eventualmente torná-lo lento ou instável. A regra é simples: cada TextEditingController e cada FocusNode criado no State deve ser descartado no dispose().
O segundo erro é criar a GlobalKey<FormState> dentro do método build. Uma chave criada no build é uma nova instância a cada rebuild, o que faz o Flutter pensar que é um Form diferente — perdendo todo o estado de validação. A chave deve ser criada como campo do State, não dentro do build.
O terceiro erro é chamar validate() sem verificar o retorno. O padrão correto é if (!_formKey.currentState!.validate()) return;. Se o retorno for ignorado, a aplicação pode continuar com o fluxo de submissão mesmo com campos inválidos — os erros aparecem visualmente nos campos, mas o código segue em frente.
O quarto erro é não verificar mounted antes de chamar setState em callbacks assíncronos. Quando uma operação await é concluída, o widget pode já ter sido removido da árvore — por exemplo, se o usuário navegou para outra tela enquanto aguardava. Chamar setState em um widget desmontado lança uma exceção. A verificação if (mounted) antes de qualquer setState em código assíncrono previne esse problema.
O quinto erro é usar context após um await sem verificar mounted. O context de um widget desmontado é inválido, e qualquer acesso — como ScaffoldMessenger.of(context) — lança exceção. Este é o mesmo problema do quarto erro, mas manifestado de forma diferente.
A Estrutura de Formulários no Projeto Integrador
Com todos os conceitos do módulo compreendidos, é hora de mapear como eles se aplicam especificamente ao Projeto Integrador e ao que será desenvolvido nas aulas práticas deste módulo.
O Projeto Integrador terá pelo menos três formulários principais que precisam ser implementados com as técnicas estudadas neste módulo. O primeiro é a tela de login, com campos de e-mail e senha, gerenciamento de foco entre eles, e navegação automática após autenticação bem-sucedida. O segundo é a tela de cadastro de conta, com os cinco campos discutidos ao longo deste material: nome, e-mail, telefone, senha e confirmação. O terceiro é a tela de edição de perfil, onde o usuário pode atualizar seus dados cadastrais e a operação usa o loading_overlay para indicar o salvamento.
Além desses formulários, o fluxo de confirmação de pedido usa um BottomSheet para seleção de endereço, e o fluxo de cancelamento de pedido usa um AlertDialog de confirmação. O SnackBar é utilizado em toda a aplicação para confirmar ações e comunicar erros.
A organização recomendada para o código dos formulários no Projeto Integrador segue o padrão estudado neste módulo: a classe Validadores é colocada em um arquivo separado em lib/core/utils/validadores.dart, os formatadores personalizados ficam em lib/core/utils/formatadores.dart, e a classe AppSnackBar fica em lib/core/utils/app_snack_bar.dart. Cada tela de formulário é um StatefulWidget em sua própria pasta de feature, na camada de apresentação, sem misturar lógica de validação com chamadas de serviço — a tela chama o serviço e trata a resposta, mas não contém lógica de negócio.
Consolidando o Aprendizado
Este módulo cobriu um conjunto coeso de ferramentas que trabalham juntas para criar formulários de qualidade profissional no Flutter. O Form com GlobalKey<FormState> coordena a validação. Os validadores extraídos para funções estáticas encapsulam as regras de negócio de forma testável. O FocusNode guia o usuário pelo preenchimento sequencial dos campos. O TextInputType e o TextInputAction configuram o teclado ideal para cada campo. O TextInputFormatter aplica máscaras em tempo real. O loading_overlay bloqueia a interface durante operações longas. O SnackBar fornece feedback não bloqueante. O AlertDialog solicita confirmação para ações irreversíveis. E o BottomSheet oferece painéis de seleção contextual.
Cada um desses recursos pode ser aprendido isoladamente, mas a qualidade real de um formulário emerge da combinação cuidadosa de todos eles. Um campo com o teclado certo mas sem fluxo de foco obriga o usuário a tocar manualmente em cada campo. Um formulário com validação perfeita mas sem feedback de carregamento convida o usuário a tocar duas vezes no botão. Um diálogo de confirmação sem botão de cancelamento é mais perturbador do que sem diálogo nenhum. A atenção ao conjunto, não apenas às partes individuais, é o que transforma um formulário funcional em um formulário excelente.
Antes da aula prática, execute todos os exemplos deste módulo no seu ambiente local. Experimente deliberadamente os erros que foram descritos: crie a GlobalKey dentro do build e observe a perda de estado de validação. Esqueça o dispose() de um controller e verifique o aviso de runtime. Remova o if (mounted) e observe quando a exceção acontece. Essas experiências deliberadas vão fixar os conceitos com muito mais eficiência do que apenas ler sobre eles.
O módulo seguinte — Gerenciamento de Estado com Provider — vai integrar os formulários com a arquitetura mais ampla da aplicação. A lógica de autenticação que hoje está inline no State do formulário de login será extraída para um ChangeNotifier gerenciado pelo Provider. Isso vai tornar o formulário mais simples e a lógica de autenticação testável. Mantenha o código que você escrever neste módulo organizado, pois você vai refatorá-lo na próxima semana.