Módulo 04 — Exercícios: Layouts e Responsividade
Estes exercícios foram elaborados para que você fixe, na prática, os conceitos de layout que estudou no material do Módulo 04. Cada um exige mais do que simplesmente reproduzir código: você precisará raciocinar sobre qual widget resolve cada problema, por que o sistema de constraints se comporta de determinada maneira, e como estruturar a árvore de widgets para que a interface seja ao mesmo tempo correta, legível e adaptável. Não há atalhos: tente resolver cada exercício do zero antes de consultar o material. O objetivo não é acertar na primeira tentativa — é desenvolver a intuição que faz um desenvolvedor Flutter saber imediatamente por que um layout quebrou e como corrigi-lo.
Exercício 1 — Nível Básico
Composição de Layout com Row, Column e Expanded
A fundação de qualquer tela: organizar informação com precisão e intenção
Quando você estudou o material deste módulo, aprendeu que Row e Column são os dois widgets mais usados no dia a dia do desenvolvimento Flutter — responsáveis por organizar a esmagadora maioria das interfaces que você verá em produção. Aprendeu também que mainAxisAlignment, crossAxisAlignment, mainAxisSize, Expanded e Flexible são as ferramentas que transformam esses dois widgets simples em um sistema de layout poderoso e preciso. Este exercício coloca tudo isso em prática dentro de um contexto diretamente ligado ao Projeto Integrador: a construção de um cartão visual detalhado para os produtos do catálogo do aplicativo.
Considere que o aplicativo do professor exibe, na tela de vitrine, um cartão para cada produto disponível no catálogo. Até agora, nas atividades do Módulo 03, você construiu uma versão inicial desse cartão. Neste exercício, você vai refazê-lo com uma atenção especial à composição de layout — cada elemento posicionado com intencionalidade, usando os widgets e propriedades corretos para cada situação.
Você vai implementar o widget CartaoProdutoDetalhado, que deve ser obrigatoriamente um StatelessWidget e receber os seguintes parâmetros nomeados e obrigatórios: nome do tipo String, categoria do tipo String, preco do tipo double, disponivel do tipo bool e descricao do tipo String. O visual do cartão deve ser construído inteiramente com Row, Column, Expanded, SizedBox, Padding e Container — sem usar ListTile, Card, Scaffold ou qualquer widget de alto nível que abstraia o posicionamento.
A estrutura interna do CartaoProdutoDetalhado deve ser a seguinte. A camada mais externa é um Container com padding uniforme de 16 pixels, margem simétrica de 8 pixels na horizontal e 6 pixels na vertical, borda arredondada de raio 12, cor de fundo branca e uma sombra sutil usando BoxShadow com blurRadius: 6 e cor preta com opacidade de 0.08. Dentro desse contêiner há uma Column com crossAxisAlignment: CrossAxisAlignment.start e mainAxisSize: MainAxisSize.min, que organiza as quatro seções descritas abaixo.
A primeira seção é uma Row que ocupa a largura total disponível e contém, à esquerda, um Expanded envolvendo o nome do produto em texto com peso FontWeight.bold e tamanho 16 — o Expanded é necessário para que nomes longos não causem overflow horizontal. À direita da Row, ainda nessa primeira seção, há um Container com padding simétrico de 6 pixels na horizontal e 3 pixels na vertical, cor de fundo Colors.blue.shade100, bordas arredondadas de raio 20, exibindo o texto da categoria com tamanho 12 e cor Colors.blue.shade800. Entre o Expanded e o Container do badge, insira um SizedBox(width: 8) para o espaçamento correto.
A segunda seção começa com um SizedBox(height: 8) e em seguida exibe o texto da descrição com tamanho 13, cor Colors.grey.shade700 e número máximo de duas linhas, com overflow: TextOverflow.ellipsis.
A terceira seção começa com um SizedBox(height: 12) e é uma Row com os seguintes elementos: à esquerda, o preço formatado como R$ X.XX (use toStringAsFixed(2)) com tamanho 18 e peso FontWeight.bold; no meio, um Spacer que empurra os elementos para as extremidades; à direita, um Container com padding horizontal de 8 e vertical de 4, cor de fundo condicional — Colors.green.shade100 se disponivel for verdadeiro, Colors.red.shade100 caso contrário — bordas arredondadas de raio 8, exibindo o texto "Disponível" ou "Indisponível" com a cor correspondente ao fundo.
A quarta seção começa com um SizedBox(height: 12) seguido de uma Row com crossAxisAlignment: CrossAxisAlignment.center. Essa Row tem dois filhos, cada um envolvido por Expanded: o primeiro é um OutlinedButton com o texto "Ver detalhes", e o segundo é um ElevatedButton com o texto "Adicionar". Entre eles, insira um SizedBox(width: 8). O ElevatedButton deve estar desabilitado — com onPressed: null — quando disponivel for falso.
Além do CartaoProdutoDetalhado, implemente um widget chamado TelaVitrine que seja um StatelessWidget e exiba uma tela completa com Scaffold. O AppBar deve ter o título "Vitrine de Produtos". O corpo da tela deve ser um SingleChildScrollView com padding de 8 pixels em todos os lados, cujo filho é uma Column com crossAxisAlignment: CrossAxisAlignment.stretch. Dentro dessa Column, exiba ao menos quatro instâncias de CartaoProdutoDetalhado com dados diferentes — inclua pelo menos um produto indisponível e pelo menos um produto com nome e descrição longos o suficiente para testar o comportamento do Expanded e do TextOverflow.ellipsis.
Finalize com uma função main que execute o aplicativo com um MaterialApp simples que exiba a TelaVitrine.
Antes de escrever qualquer linha de código, pense nas seguintes questões. Por que o Expanded ao redor do nome do produto na primeira Row é necessário — o que aconteceria se ele não estivesse lá e o nome fosse longo? Por que o Spacer entre o preço e o badge de disponibilidade é mais semântico do que um Expanded(child: SizedBox())? O mainAxisSize: MainAxisSize.min na Column interna do cartão tem algum efeito visível neste contexto específico, ou seria irrelevante? Tente responder mentalmente antes de rodar o código.
Preste atenção especial à quarta seção, onde os dois botões dividem o espaço da Row igualmente graças aos dois Expanded. Experimente remover os Expanded e observe o que acontece. Em seguida, experimente substituir os dois Expanded por Flexible com flex: 1 em cada um e verifique se o resultado é idêntico — e pense por quê. Esse tipo de comparação deliberada acelera muito o aprendizado do sistema de layout.
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
Catálogo Responsivo com ListView, GridView, Wrap e LayoutBuilder
Construir interfaces que se adaptam ao conteúdo e ao dispositivo é o que distingue um app profissional
No material deste módulo, você aprendeu que um aplicativo móvel real precisa lidar com variações de conteúdo — uma lista que pode ter três ou trezentos itens — e variações de dispositivo — do smartphone compacto ao tablet espaçoso. O ListView.builder resolve o primeiro desafio de forma eficiente, construindo apenas os itens visíveis na tela. O GridView.builder oferece uma organização em grade quando o espaço horizontal é generoso. O Wrap permite exibir grupos de elementos que “quebram linha” automaticamente. E o LayoutBuilder torna possível tomar decisões de layout baseadas no espaço real disponível para um widget — sem depender das dimensões globais da tela. Este exercício combina todos esses recursos em uma tela de catálogo de produtos funcional.
Você vai construir a tela TelaCatalogoProdutos, que é o componente central do fluxo de compras do aplicativo do professor. Ela exibe o catálogo completo de produtos, permite filtrar por categoria e adapta sua apresentação ao tamanho da tela disponível. A tela deve ser um StatefulWidget e gerenciar dois estados em seu State: a lista completa de produtos e a categoria atualmente selecionada para filtragem.
Comece definindo a classe Produto com os campos id do tipo String, nome do tipo String, categoria do tipo String, preco do tipo double, disponivel do tipo bool e descricao do tipo String. Não é necessário implementar serialização ou construtores avançados — o foco deste exercício é inteiramente no layout.
O estado da TelaCatalogoProdutos deve conter uma lista de pelo menos dez produtos com no mínimo três categorias distintas — por exemplo, "Lanches", "Bebidas" e "Sobremesas". Inclua produtos indisponíveis e produtos com nomes e descrições de comprimentos variados, pois isso testa o comportamento do layout sob condições reais. O estado também deve conter uma String? chamada categoriaSelecionada, inicializada com null, que representa o filtro ativo — quando null, todos os produtos são exibidos.
A lista filtrada de produtos não deve ser armazenada como um segundo estado, mas sim calculada dentro do método build a partir dos dois estados existentes: se categoriaSelecionada for null, use todos os produtos; caso contrário, use apenas os produtos cuja categoria seja igual à categoriaSelecionada. Isso evita inconsistências entre os dois estados e é a abordagem correta para dados derivados.
A estrutura visual da tela deve ser um Scaffold com AppBar de título "Catálogo". O corpo deve ser organizado em uma Column com dois filhos. O primeiro filho é a área de filtros; o segundo é a área de listagem, que deve ser envolvida em Expanded para que ocupe todo o espaço vertical restante.
A área de filtros deve ter padding de 12 pixels em todos os lados e conter dois elementos dispostos verticalmente com SizedBox(height: 8) de separação. O primeiro elemento é um Wrap com spacing: 8 e runSpacing: 8 que exibe um FilterChip para cada categoria distinta presente na lista de produtos. Para obter as categorias sem repetição, use o encadeamento de coleções do Dart: extraia as categorias, converta para um Set para eliminar duplicatas e itere sobre o resultado. Cada FilterChip deve ter label: Text(categoria), selected: categoriaSelecionada == categoria e o callback onSelected deve chamar setState para atualizar categoriaSelecionada — se a categoria clicada já estiver selecionada, defina categoriaSelecionada como null para desmarcar o filtro. O segundo elemento da área de filtros é um texto informativo que exibe a quantidade de produtos visíveis, como "Exibindo 7 produtos", obtida a partir do comprimento da lista filtrada, com tamanho 13 e cor Colors.grey.shade600.
A área de listagem deve conter um LayoutBuilder que decide qual apresentação usar com base na largura disponível. Se constraints.maxWidth for menor que 600 pixels, use um ListView.builder para exibir os produtos em lista vertical. Se for maior ou igual a 600 pixels, use um GridView.builder com SliverGridDelegateWithFixedCrossAxisCount de crossAxisCount: 2, crossAxisSpacing: 12, mainAxisSpacing: 12 e childAspectRatio: 1.2. Em ambos os casos, se a lista filtrada estiver vazia, não exiba a lista ou a grade — exiba, em seu lugar, um Center com uma Column contendo um Icon(Icons.search_off, size: 64, color: Colors.grey) e abaixo um texto "Nenhum produto encontrado nessa categoria" com cor cinza e alinhamento centralizado.
O widget de item da lista pode ser simples: um Card com margin e Padding interno exibindo o nome do produto em negrito, a categoria em texto menor e cinza, o preço formatado e um indicador de disponibilidade. Não é necessário reutilizar o CartaoProdutoDetalhado do exercício anterior — mas você pode fazê-lo se quiser praticar a composição de widgets.
O ponto mais delicado deste exercício é entender por que o segundo filho da Column precisa ser envolvido em Expanded. Sem ele, o LayoutBuilder — e dentro dele o ListView.builder ou o GridView.builder — receberá uma constraint de altura infinita, o que causará o clássico erro de “Vertical viewport was given unbounded height”. O Expanded faz com que esse filho receba exatamente o espaço vertical que sobrou após a área de filtros ser medida. Isso é a regra de constraints em ação.
Por que usar LayoutBuilder em vez de simplesmente MediaQuery? Pense no seguinte cenário: e se essa tela de catálogo for incorporada como um painel dentro de uma tela maior, onde ela tem apenas metade da largura total da tela disponível? Nesse caso, MediaQuery daria a largura total da tela — que seria enganosa — enquanto LayoutBuilder daria a largura real do espaço que o widget tem disponível. Essa diferença é o que torna um componente verdadeiramente reutilizável.
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 Principal com SliverAppBar, SafeArea, Breakpoints e Stack
O desafio que une os conceitos avançados de layout em uma tela completa e profissional
Um aplicativo que se comporta de maneira elegante em qualquer dispositivo — do smartphone compacto ao tablet — e que apresenta efeitos de rolagem sofisticados, como o cabeçalho que se expande e recolhe ao rolar a lista, é reconhecido imediatamente como um produto de qualidade. Neste exercício, você vai construir a tela principal autenticada do aplicativo do professor combinando, de forma coerente e integrada, os conceitos mais avançados do módulo: CustomScrollView com SliverAppBar, SafeArea, uma classe utilitária de breakpoints responsivos, ConstrainedBox para suporte a tablets e Stack com Positioned para um elemento flutuante que informa o estado do pedido em andamento.
Este exercício é dividido em três partes interdependentes que devem funcionar em conjunto como uma única tela completa.
Parte 1: A classe de breakpoints. Implemente a classe Responsivo com três constantes privadas: _larguraMobile igual a 600.0, _larguraTablet igual a 900.0 e nenhuma para desktop (tudo acima de _larguraTablet é tratado como desktop). Os métodos estáticos públicos que você deve implementar são isMobile(BuildContext context), que retorna true se a largura da tela for menor que _larguraMobile; isTablet(BuildContext context), que retorna true se a largura estiver entre _larguraMobile (inclusive) e _larguraTablet (exclusive); e isDesktop(BuildContext context), que retorna true para larguras maiores ou iguais a _larguraTablet. Implemente também o método genérico valor<T>({required BuildContext context, required T mobile, T? tablet, T? desktop}), que retorna o valor correspondente ao tamanho de tela detectado, priorizando desktop sobre tablet sobre mobile quando os valores opcionais estiverem disponíveis.
Parte 2: A tela principal com Slivers. Implemente o widget TelaPrincipal como um StatefulWidget. O estado deve gerenciar três informações: _nomeUsuario do tipo String com um valor inicial de sua escolha (representando o usuário logado), _enderecoEntrega do tipo String com um endereço inicial, e _quantidadeItensNoPedido do tipo int inicializado com zero. A tela deve expor um método void _adicionarAoPedido() que chama setState para incrementar _quantidadeItensNoPedido.
O Scaffold da TelaPrincipal deve ter backgroundColor: Colors.grey.shade100. O corpo inteiro deve ser um Stack com dois filhos. O primeiro filho é o CustomScrollView; o segundo é um widget posicionado que aparece apenas quando há itens no pedido, descrito na Parte 3.
O CustomScrollView deve ter a lista slivers com três elementos. O primeiro é um SliverAppBar com expandedHeight: 200, pinned: true e floating: false. O flexibleSpace deve ser um FlexibleSpaceBar cujo background é um Stack com dois filhos: um Container com gradiente linear de Colors.orange.shade700 para Colors.deepOrange.shade900 ocupando toda a área via Positioned.fill; e um Positioned com bottom: 16 e left: 20 contendo uma Column com mainAxisSize: MainAxisSize.min e crossAxisAlignment: CrossAxisAlignment.start que exibe, em sequência, um texto "Entrega em" com cor branca e opacidade reduzida (tamanho 13), o valor de _enderecoEntrega com cor branca, peso FontWeight.bold e tamanho 16, e uma Row com um Icon(Icons.person_outline, color: Colors.white70, size: 14), um SizedBox(width: 4) e o texto "Olá, $_nomeUsuario" com cor Colors.white70 e tamanho 13. O title do SliverAppBar deve exibir apenas o texto "Início" com cor branca.
O segundo elemento da lista slivers deve ser um SliverToBoxAdapter cujo filho é um SafeArea com top: false (o topo já é tratado pelo SliverAppBar) envolvendo um Center com um ConstrainedBox(constraints: const BoxConstraints(maxWidth: 800)). Dentro do ConstrainedBox, coloque um Padding com EdgeInsets.symmetric(horizontal: 16, vertical: 12) e, como filho, um Wrap de categorias similar ao que você construiu no Exercício 2 — com FilterChips das categorias disponíveis. Acima do Wrap, inclua um texto "O que você quer hoje?" com tamanho 20, peso FontWeight.bold e padding inferior de 12 pixels.
O terceiro elemento da lista slivers deve ser um SliverPadding com padding: const EdgeInsets.fromLTRB(16, 0, 16, 100) — o padding inferior de 100 garante que o conteúdo do final da lista não fique escondido atrás do botão flutuante. Dentro do SliverPadding, use Responsivo.isMobile(context) para decidir entre dois delegates: se for mobile, use SliverList com SliverChildBuilderDelegate exibindo itens de produto em lista; se não for mobile, use SliverGrid com SliverGridDelegateWithFixedCrossAxisCount cujo crossAxisCount é determinado por Responsivo.valor<int>(context: context, mobile: 1, tablet: 2, desktop: 3). Em ambos os casos, crie pelo menos oito itens fictícios de produto. Cada item deve ser um Card com um InkWell cujo onTap chama _adicionarAoPedido().
Parte 3: O botão flutuante de pedido. O segundo filho do Stack externo (o que envolve o CustomScrollView) deve ser visível apenas quando _quantidadeItensNoPedido > 0. Use uma expressão condicional na lista children do Stack: se a quantidade for zero, insira um const SizedBox.shrink(); caso contrário, insira o widget descrito a seguir. Esse widget é um Positioned com bottom: 16, left: 24 e right: 24 envolvendo um SafeArea com top: false e left: false e right: false. Dentro do SafeArea, coloque um Material com elevation: 8, borderRadius: BorderRadius.circular(16), color: Colors.deepOrange e como filho um InkWell com borderRadius: BorderRadius.circular(16), onTap vazio por ora, e como filho um Padding de EdgeInsets.symmetric(horizontal: 20, vertical: 14) contendo uma Row com os seguintes elementos: um Container com padding de 4 pixels, cor de fundo Colors.white.withOpacity(0.25), bordas arredondadas de raio 8 e filho Text('$_quantidadeItensNoPedido', style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold)); um Spacer; um texto "Ver pedido" em branco com tamanho 16 e peso FontWeight.bold; um outro Spacer; e um Icon(Icons.arrow_forward_ios, color: Colors.white, size: 16).
O aspecto mais delicado desta implementação é o SafeArea combinado com o SliverAppBar. Usando SafeArea com top: false dentro do SliverToBoxAdapter, você está dizendo: “não preciso de proteção no topo aqui, pois o SliverAppBar já cuida disso”. Já o SafeArea com top: false no botão flutuante garante que ele respeite a barra de gestos do Android e o home indicator do iOS na borda inferior — sem cobrir o conteúdo do sistema. Se você remover esses SafeAreas, teste o resultado em um dispositivo com notch ou barra de gestos para ver o que acontece na prática.
O ConstrainedBox dentro do SliverToBoxAdapter tem um papel importante em tablets: ele evita que o Wrap de categorias se estique por 900 pixels de largura, o que ficaria visualmente estranho. No entanto, para que o ConstrainedBox funcione como esperado neste contexto, o Center ao redor dele é necessário — sem o Center, o ConstrainedBox ainda vai até a largura máxima do pai, mas o filho do ConstrainedBox não ficará centralizado. Experimente remover o Center e observe o comportamento em uma tela larga para entender essa interação.
O padding inferior de 100 pixels no SliverPadding é uma técnica prática para evitar que o último item da lista fique escondido atrás do botão flutuante. Uma alternativa mais robusta seria usar MediaQuery.of(context).padding.bottom acrescido da altura estimada do botão para calcular o padding dinamicamente. Considere implementar essa alternativa como melhoria após ter a versão básica funcionando.
O que deve ser entregue: um arquivo chamado g_ex3.dart, onde g é o nome do seu grupo.
Se você concluiu as três partes com sucesso, considere este desafio adicional: como você tornaria o ConstrainedBox da área de categorias desnecessário sem perder o comportamento centralizado em tablets? O widget FractionallySizedBox poderia ser uma alternativa — explore como ele se comporta em comparação com ConstrainedBox dentro de Center quando a largura da tela varia. Além disso, pense em como o AspectRatio poderia ser usado para garantir que cada card de produto na grade mantenha sempre a mesma proporção independentemente do número de colunas.