Módulo 06 — Exercícios: Formulários, Validação e Feedback ao Usuário
Estes exercícios foram elaborados para que você coloque em prática os conceitos de formulários, validação e feedback ao usuário estudados no material do Módulo 06. Formular uma entrada de dados corretamente em Flutter não é apenas colocar campos na tela: é uma composição cuidadosa de validadores, configurações de teclado, fluxo de foco, indicadores de carregamento e mecanismos de feedback que juntos produzem uma experiência fluida e confiável. Tente resolver cada exercício sem consultar o material antes de chegar ao final do enunciado. O esforço de resolver com o que você reteve é o que consolida o aprendizado.
Exercício 1 — Nível Básico
Formulário de Login com Validação e Fluxo de Foco
A porta de entrada do aplicativo: um formulário simples, correto e bem comportado
Todo aplicativo com autenticação começa com uma tela de login. Implementar essa tela corretamente exige dominar os elementos fundamentais que você estudou neste módulo: a estrutura Form com GlobalKey<FormState>, o TextFormField com validadores, o gerenciamento de foco entre os dois campos e o feedback adequado ao usuário ao final da operação. Este exercício é o ponto de entrada obrigatório para todos os demais conceitos do módulo.
Você vai implementar a tela de login do aplicativo de pedidos do professor. Toda a implementação deve estar em um único arquivo Dart que seja executável com flutter run, contendo o main e todos os widgets necessários.
O widget principal é TelaLogin, um StatefulWidget que gerencia o formulário de autenticação. O estado deve manter uma GlobalKey<FormState> chamada _formKey, dois TextEditingControllers — um para o e-mail e outro para a senha —, um FocusNode para o campo de senha e um bool _carregando inicializado como false. Todos os controllers e o FocusNode devem ser descartados corretamente no dispose().
O visual da tela deve ser um Scaffold com AppBar de título "Entrar". O corpo deve ser um SingleChildScrollView com padding simétrico de 24 pixels. Dentro dele, um Form com a _formKey deve conter uma Column com os elementos descritos a seguir.
O primeiro elemento é um logotipo simulado: um Icon(Icons.fastfood, size: 72) seguido de um Text("Pedidos App") com tamanho 24 e peso FontWeight.bold, ambos centralizados com mainAxisAlignment: MainAxisAlignment.center numa Row. Abaixo, um SizedBox(height: 40).
O segundo elemento é o campo de e-mail: um TextFormField com controller adequado, decoration com labelText: 'E-mail', prefixIcon: Icon(Icons.email_outlined) e border: OutlineInputBorder(), keyboardType: TextInputType.emailAddress, textInputAction: TextInputAction.next, onFieldSubmitted que move o foco para o campo de senha usando FocusScope.of(context).requestFocus(_senhaFocus), e um validator que retorna mensagem de erro se o valor for nulo, vazio ou não contiver @ — use a expressão regular RegExp(r'^[^@]+@[^@]+\.[^@]+$') para verificar o formato.
O terceiro elemento é o campo de senha, separado por SizedBox(height: 16): um TextFormField com o focusNode de senha, controller adequado, decoration com labelText: 'Senha', prefixIcon: Icon(Icons.lock_outline) e border: OutlineInputBorder(), obscureText: true, textInputAction: TextInputAction.done, onFieldSubmitted que chama o método de submissão do formulário, e um validator que retorna mensagem de erro se o valor for nulo ou tiver menos de seis caracteres.
O quarto elemento é o botão, separado por SizedBox(height: 24): um ElevatedButton com onPressed: _carregando ? null : _submeter, texto "Entrar" e style com padding vertical de 16 pixels e minimumSize: Size(double.infinity, 0) para que ocupe toda a largura disponível.
O método _submeter deve: primeiro chamar _formKey.currentState!.validate() e retornar imediatamente se o resultado for false; depois chamar FocusScope.of(context).unfocus() para fechar o teclado; em seguida chamar setState para definir _carregando = true; aguardar Future.delayed(const Duration(seconds: 2)) simulando a chamada ao serviço de autenticação; e ao final, se mounted for verdadeiro, chamar setState para definir _carregando = false e exibir um SnackBar com a mensagem "Login realizado com sucesso!" e backgroundColor: Colors.green. Certifique-se de usar try/finally para garantir que _carregando seja sempre redefinido como false, mesmo que uma exceção ocorra.
A função main deve executar o aplicativo com MaterialApp simples que exibe a TelaLogin como home.
Antes de começar a codificar, responda mentalmente a estas perguntas. Por que a GlobalKey<FormState> deve ser declarada como campo do State e não dentro do método build? O que acontece com os TextEditingControllers se o dispose() não for chamado? Por que o botão tem onPressed: _carregando ? null : _submeter em vez de simplesmente onPressed: _submeter? E qual é a diferença entre FocusScope.of(context).unfocus() e FocusScope.of(context).requestFocus(_algumFocus) em termos do que acontece com o teclado?
Preste atenção especial ao bloco try/finally dentro de _submeter. Sem o finally, se a linha await Future.delayed(...) lançar uma exceção, o setState(() => _carregando = false) nunca será chamado e o botão ficará permanentemente desabilitado. O finally garante que o estado de carregamento seja sempre revertido, independentemente de sucesso ou falha. Teste isso propositalmente: substitua o Future.delayed por throw Exception('Erro de rede') e observe o comportamento com e sem o finally.
O que deve ser entregue: um arquivo chamado g_ex1.dart, onde g é o nome do seu grupo.
Exercício 2 — Nível Intermediário
Formulário de Cadastro com Máscaras, Validadores Customizados e loading_overlay
Coletando dados com qualidade: validação rigorosa, máscaras e bloqueio de interface durante operações longas
O formulário de cadastro de um aplicativo de pedidos é mais complexo do que o de login: tem mais campos, cada um com sua própria lógica de validação, e a operação de criação de conta pode demorar vários segundos, exigindo um mecanismo de bloqueio de interface mais robusto do que um simples botão desabilitado. Este exercício combina formatadores de entrada, uma biblioteca de validadores reutilizáveis, gerenciamento completo de foco e o pacote loading_overlay em uma única tela funcional.
Você vai implementar o formulário de cadastro de novo usuário do Projeto Integrador. O arquivo deve ser executável com flutter run e deve incluir no pubspec.yaml a dependência loading_overlay: ^0.5.0 — lembre-se de rodar flutter pub get antes de testar.
Comece implementando a classe Validadores com um construtor privado Validadores._(). Ela deve ter os seguintes métodos estáticos. O método obrigatorio(String? valor, {String campo = 'Este campo'}) deve retornar '$campo é obrigatório' se o valor for nulo ou vazio após trim(), e null caso contrário. O método nomeCompleto(String? valor) deve primeiro usar obrigatorio e, se passar, verificar se há pelo menos dois termos não vazios separados por espaço — se não houver, retorna 'Informe nome e sobrenome'. O método email(String? valor) deve primeiro usar obrigatorio e, se passar, verificar o formato com RegExp(r'^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$') — se inválido, retorna 'Informe um e-mail válido'. O método telefone(String? valor) deve primeiro usar obrigatorio e, se passar, remover todos os caracteres não numéricos com replaceAll(RegExp(r'\D'), '') e verificar se o resultado tem entre 10 e 11 dígitos — se não, retorna 'Informe um telefone válido com DDD'. O método senha(String? valor) deve primeiro usar obrigatorio e, se passar, verificar: comprimento mínimo de 8 caracteres, presença de pelo menos uma letra maiúscula RegExp(r'[A-Z]') e presença de pelo menos um dígito RegExp(r'[0-9]') — cada falha deve retornar sua mensagem específica. Por fim, o método estático confirmarSenha(String senhaOriginal) deve retornar uma função String? Function(String?) que verifica se o valor é igual a senhaOriginal e, se não for, retorna 'As senhas não coincidem'.
Em seguida, implemente o TelefoneFormatter, um TextInputFormatter que formata o número enquanto o usuário digita. Ele deve extrair apenas os dígitos do novo valor, limitar a 11 dígitos, e aplicar a máscara (XX) XXXXX-XXXX para 11 dígitos ou (XX) XXXX-XXXX para 10 ou menos. O cursor deve sempre ficar ao final do texto formatado.
O widget principal é TelaCadastro, um StatefulWidget. O estado deve gerenciar uma GlobalKey<FormState>, cinco TextEditingControllers (nome, e-mail, telefone, senha e confirmação), quatro FocusNodes (para e-mail, telefone, senha e confirmação), um bool _carregando e um bool _senhaVisivel e um bool _confirmacaoVisivel — ambos iniciados como false e usados para alternar a visibilidade dos campos de senha. Todos os recursos devem ser descartados no dispose().
A tela deve ser envolvida pelo LoadingOverlay com isLoading: _carregando, color: Colors.black e opacity: 0.5. O corpo interno é um Scaffold com AppBar de título "Criar conta" e corpo SingleChildScrollView com padding de 24 pixels. O formulário deve ter autovalidateMode: AutovalidateMode.onUserInteraction e conter os cinco campos na seguinte ordem e configuração:
O campo de nome usa nomeCompleto como validador, textCapitalization: TextCapitalization.words, TextInputAction.next e move o foco para o campo de e-mail ao submeter. O campo de e-mail usa email como validador, TextInputType.emailAddress, TextInputAction.next e move o foco para o campo de telefone. O campo de telefone usa telefone como validador, TextInputType.phone, os formatadores FilteringTextInputFormatter.digitsOnly e TelefoneFormatter(), TextInputAction.next e move o foco para o campo de senha. O campo de senha usa senha como validador, obscureText: !_senhaVisivel, um suffixIcon com IconButton que alterna _senhaVisivel, TextInputAction.next e move o foco para a confirmação. O campo de confirmação usa Validadores.confirmarSenha(_senhaController.text) como validador, obscureText: !_confirmacaoVisivel, um suffixIcon análogo ao campo de senha, TextInputAction.done e chama o método de submissão ao submeter.
O método de submissão deve validar o formulário, fechar o teclado, ativar o carregamento, aguardar dois segundos simulando a API e, ao final, exibir um SnackBar de sucesso ou erro dentro de um try/finally com verificação de mounted.
O ponto mais delicado deste exercício é o Validadores.confirmarSenha(_senhaController.text). Observe que _senhaController.text é lido no momento em que o validator é montado — ou seja, no build. Se o usuário alterar a senha depois, o validador de confirmação continuará comparando com o valor antigo. Para que a confirmação seja sempre avaliada com o valor atual da senha, considere este detalhe: quando você altera o campo de senha, o campo de confirmação precisa ser revalidado. Uma forma simples de garantir isso é adicionar um onChanged no campo de senha que chame _formKey.currentState?.validate() — mas apenas se o autovalidateMode estiver ativo. Reflita sobre se isso é necessário neste exercício ou se o comportamento padrão já é suficiente para o caso de uso.
A diferença entre envolver o Scaffold com LoadingOverlay versus envolver apenas o corpo é significativa. Quando o overlay cobre apenas o corpo, a AppBar permanece interativa durante o carregamento — o usuário pode tocar no botão de voltar e sair da tela enquanto a operação ainda está em andamento, o que pode causar comportamentos inesperados. Ao envolver o Scaffold inteiro, toda a tela fica bloqueada. Para o fluxo de cadastro, bloquear a tela inteira é o comportamento correto. Verifique isso na sua implementação.
O que deve ser entregue: um arquivo chamado g_ex2.dart, onde g é o nome do seu grupo.
Exercício 3 — Nível Desafiador
Tela de Confirmação de Pedido com BottomSheet, AlertDialog e Validação Assíncrona de Servidor
O desafio que integra formulários, feedback ao usuário e lógica assíncrona em um fluxo completo
Um aplicativo de pedidos não é feito apenas de formulários de cadastro. O fluxo de finalização de um pedido também envolve coleta e validação de informações: o usuário seleciona o endereço de entrega, informa um cupom de desconto, e confirma os dados antes de enviar. Cada passo desse fluxo usa um mecanismo diferente de feedback e interação. Este exercício integra o BottomSheet de seleção, o AlertDialog de confirmação, a validação assíncrona de cupom e o loading_overlay em uma única tela coesa.
:::
Você vai implementar a tela de confirmação de pedido do Projeto Integrador. O arquivo deve ser executável com flutter run, incluir a dependência loading_overlay: ^0.5.0 e implementar todos os widgets e modelos de dados necessários em um único arquivo. Reutilize os Validadores e o TelefoneFormatter do exercício anterior se desejar — ou reimplemente apenas o que for necessário.
Os modelos de dados. Defina a classe Endereco com campos id e logradouro do tipo String e um campo opcional complemento do tipo String (padrão ''). Defina a classe ItemPedido com campos nomeProduto do tipo String, quantidade do tipo int e precoUnitario do tipo double. Implemente um getter subtotal que retorna quantidade * precoUnitario. Não é necessário implementar serialização.
A tela principal. O widget TelaConfirmarPedido é um StatefulWidget. O estado deve gerenciar os seguintes campos: uma lista de endereços _enderecos (ao menos três instâncias de Endereco definidas no initState ou como constante), o endereço selecionado _enderecoSelecionado inicializado com o primeiro da lista, uma lista de itens do pedido _itensPedido com ao menos dois ItemPedido com preços e quantidades fictícios, um TextEditingController para o campo de cupom chamado _cupomController, uma GlobalKey<FormState> para o mini-formulário de cupom, um String? chamado _erroCupomServidor inicializado como null e usado para erros assíncronos de validação de cupom, um double _desconto inicializado como 0.0, e um bool _carregando inicializado como false.
O layout da tela. A tela deve ser envolvida por LoadingOverlay com isLoading: _carregando. O Scaffold interno deve ter AppBar de título "Confirmar Pedido". O corpo deve ser um SingleChildScrollView com padding de 16 pixels, contendo uma Column com crossAxisAlignment: CrossAxisAlignment.stretch que organiza as quatro seções descritas a seguir, separadas por SizedBox(height: 16).
Seção 1 — Itens do pedido. Um Card com cabeçalho "Resumo do pedido" em texto negrito e uma Column com um ListTile para cada item, exibindo nomeProduto como title, "${quantidade}x" como leading e "R\$ ${subtotal.toStringAsFixed(2)}" como trailing. Abaixo dos itens, um Divider, e então uma Row com o texto "Total:" e o valor total calculado como a soma de todos os subtotal menos o desconto, formatado como "R\$ X.XX". O valor total deve ser exibido em negrito. Se _desconto > 0, exiba também uma linha adicional mostrando o desconto aplicado em cor verde antes do total.
Seção 2 — Endereço de entrega. Um Card com cabeçalho "Endereço de entrega" e, dentro do corpo, um ListTile com o logradouro do endereço selecionado como title, o complemento como subtitle (quando não vazio), Icon(Icons.location_on_outlined) como leading e um TextButton("Alterar") como trailing que chama o método _selecionarEndereco().
O método _selecionarEndereco deve usar showModalBottomSheet com isScrollControlled: true e shape com BorderRadius.vertical(top: Radius.circular(20)). O conteúdo do painel deve ter uma alça decorativa no topo, um título "Selecionar endereço", um Divider e uma ListTile para cada endereço da lista — cada ListTile deve exibir um Icon(Icons.radio_button_checked) colorido com a cor primária quando o endereço é o selecionado atualmente, e Icon(Icons.radio_button_off) nos demais. Ao tocar em um endereço, o painel fecha com Navigator.of(context).pop(endereco). Após o await do showModalBottomSheet, atualize _enderecoSelecionado se o resultado for não nulo.
Seção 3 — Cupom de desconto. Um Card com cabeçalho "Cupom de desconto" e um corpo contendo um Form com a _cupomFormKey. Dentro do Form, uma Row com dois filhos: o primeiro é um Expanded envolvendo um TextFormField com controller: _cupomController, decoration com labelText: 'Código do cupom' e border: OutlineInputBorder(), e um validator que verifica primeiro o erro assíncrono _erroCupomServidor e, se nulo, retorna null sem validação local (o cupom é opcional); o segundo filho é um ElevatedButton com texto "Aplicar" que chama o método _aplicarCupom(). Se o desconto já estiver ativo (_desconto > 0), exiba abaixo do campo um Text("Cupom aplicado: $_desconto% de desconto", style: TextStyle(color: Colors.green)).
O método _aplicarCupom deve primeiro limpar _erroCupomServidor com setState, depois validar o formulário de cupom com _cupomFormKey.currentState!.validate(), fechar o teclado, ativar o carregamento (_carregando = true), aguardar Future.delayed(Duration(seconds: 1)) simulando a consulta ao servidor e, ao final, dentro do finally, desativar o carregamento. O comportamento deve simular duas situações: se o texto do cupom for "PROMO10" (insensível a maiúsculas — use toUpperCase()), aplique 10% de desconto definindo _desconto = 10.0 e exiba SnackBar de sucesso; caso contrário, defina _erroCupomServidor = 'Cupom inválido ou expirado', chame _cupomFormKey.currentState!.validate() para que a mensagem apareça no campo e exiba SnackBar de erro.
Seção 4 — Botão de confirmar. Um ElevatedButton com onPressed: _carregando ? null : _confirmarPedido, texto "Confirmar pedido" e style com padding vertical de 16 pixels.
O método _confirmarPedido deve primeiro exibir um AlertDialog de confirmação usando showDialog com barrierDismissible: false. O diálogo deve ter título "Confirmar pedido?", conteúdo com um texto descrevendo o total e o endereço de entrega selecionado, e dois botões: "Cancelar" que faz Navigator.of(ctx).pop(false) e "Confirmar" que faz Navigator.of(ctx).pop(true). Se o usuário não confirmar (resultado false ou null), o método retorna sem fazer nada. Se confirmar, ativa o carregamento, aguarda dois segundos simulando o envio do pedido e, ao final, exibe SnackBar de sucesso com a mensagem "Pedido enviado com sucesso!".
O aspecto mais delicado deste exercício é a interação entre o _erroCupomServidor e o validator do campo de cupom. O validator é uma função síncrona chamada pelo Form. O erro do servidor, no entanto, chega de forma assíncrona. O padrão correto é: o erro chega → você define _erroCupomServidor com setState → você chama _cupomFormKey.currentState!.validate() manualmente para forçar o Form a re-executar o validator → o validator, agora que _erroCupomServidor não é mais nulo, retorna a mensagem de erro → o campo exibe a mensagem. E quando o usuário começa a corrigir o campo, você deve limpar _erroCupomServidor com setState para que o erro de servidor desapareça — isso pode ser feito no onChanged do TextFormField ou no início do _aplicarCupom.
Há um detalhe de contexto nos diálogos e painéis que merece atenção. Tanto o showDialog quanto o showModalBottomSheet usam um BuildContext para se posicionar na árvore de widgets. Dentro do builder de cada um, o contexto recebido como parâmetro — geralmente nomeado como dialogContext ou sheetContext — é o contexto do próprio diálogo ou painel, não o contexto da tela que o chamou. Usar o contexto errado para fechar o diálogo com Navigator.of(context).pop(...) pode fechar a tela errada. Sempre use o contexto local do builder — o recebido como parâmetro — para fechar o diálogo ou painel. Para ações que devem operar sobre a tela principal (como o SnackBar após o fechamento do painel), use o context da tela que chamou o painel, mas sempre com a verificação if (mounted).
O que deve ser entregue: um arquivo chamado g_ex3.dart, onde g é o nome do seu grupo.
Se você concluiu os três exercícios com sucesso, considere este desafio adicional para o exercício 3: como você implementaria a persistência do endereço de entrega selecionado entre sessões do aplicativo? Em um aplicativo real, o usuário não deveria precisar selecionar o endereço toda vez que fizer um pedido — o último endereço usado deveria ser pré-selecionado. No Módulo 08, você aprenderá o SharedPreferences para isso. Mas por ora, reflita sobre o que seria necessário para que a seleção de endereço persista mesmo após o widget ser descartado da árvore: onde esse estado deveria residir — no State do widget, em um objeto global, ou em um serviço de repositório?