Módulo 09 — Exercícios: Consumo de APIs REST

Chegamos ao ponto em que o seu aplicativo de delivery deixa de ser uma ilha isolada e passa a se comunicar com o mundo. Até agora, os dados eram locais, estáticos ou persistidos apenas no dispositivo do usuário. A partir deste módulo, você aprende a fazer o que todo aplicativo moderno de produção faz: consumir uma API REST real, enviar e receber dados em JSON, tratar falhas de rede com elegância e integrar tudo isso ao Provider de forma que a interface reaja de maneira fluida às mudanças de estado. Os três exercícios a seguir foram desenhados em progressão deliberada: o primeiro constrói a base da comunicação HTTP com GET e desserialização; o segundo adiciona a escrita de dados com POST e PATCH dentro de um ChangeNotifier; o terceiro fecha o ciclo com a camada de repositório completa, cache local, retry e a arquitetura hexagonal que o seu projeto integrador exige. Leia cada enunciado com atenção antes de escrever qualquer linha de código, pois as restrições são tão importantes quanto os requisitos.


Exercício 1

CardápioRemoto: GET, URIs e desserialização JSON

Buscar dados de uma API real exige mais do que apenas chamar um método — exige verificação de status, tratamento de exceções e construção correta de URIs

O primeiro contato com consumo de API quase sempre produz o mesmo padrão problemático: chama-se http.get() de forma estática, passa-se a URL como string concatenada, aplica-se jsonDecode direto no corpo da resposta sem verificar o código de status e presume-se que o JSON sempre chegará no formato esperado. Esse padrão funciona em ambiente de desenvolvimento, com dados controlados, mas colapsa em produção assim que o servidor retorna um 404, um 500 ou até mesmo um 200 com corpo vazio por alguma condição de corrida. Este exercício existe para que você construa, desde o início, o hábito de tratar cada etapa da comunicação HTTP com a devida atenção.

O cenário é o seguinte: o aplicativo de delivery precisa de uma tela que carrega e exibe o cardápio completo de produtos disponíveis na API. Essa tela deve ser robusta o suficiente para lidar com falhas de rede, respostas inesperadas do servidor e o fato de que o campo preco no JSON pode chegar como número inteiro (29) ou como número de ponto flutuante (29.90), dependendo do produto. A tarefa é implementar, em um único arquivo Dart chamado g_ex1.dart (onde g é o nome do seu grupo), todos os elementos descritos a seguir.

O ponto de partida é a classe ProdutoRemoteDataSource. Ela é responsável exclusivamente por se comunicar com a API: recebe um http.Client e um String token no seu construtor e expõe dois métodos assíncronos. O método listarProdutos() deve fazer uma requisição GET para o endpoint /produtos no host api.delivery.example.com, retornando Future<List<Produto>>. O método buscarProduto(int id) deve fazer uma requisição GET para /produtos/{id} no mesmo host, retornando Future<Produto>. Ambos os métodos devem construir a URI utilizando Uri.https() — nunca por concatenação de strings — e devem enviar o cabeçalho Authorization: Bearer {token} em todas as requisições. Ambos devem configurar um timeout de 10 segundos usando .timeout(const Duration(seconds: 10)). Quanto ao tratamento de status: uma resposta 200 deve resultar na desserialização do corpo JSON; uma resposta 404 em buscarProduto deve lançar uma NotFoundException; qualquer outro código de status deve lançar uma ApiException contendo o código recebido e o corpo da resposta.

O modelo Produto deve ter os campos id (int), nome (String), categoria (String), preco (double), disponivel (bool) e descricao (String). O método de fábrica fromJson deve extrair cada campo do mapa JSON corretamente, com atenção especial para o campo preco: como ele pode chegar como int ou double, você deve usar a expressão (json['preco'] as num).toDouble() para garantir a conversão correta em qualquer caso. O cast direto json['preco'] as double lançará uma exceção de tipo quando o servidor enviar 29 em vez de 29.0, e isso é exatamente o tipo de bug que só aparece com dados reais.

As exceções NotFoundException e ApiException também fazem parte do arquivo. A NotFoundException pode ser uma classe simples sem campos adicionais. A ApiException deve ter dois campos obrigatórios: int codigo e String mensagem, pois eles serão necessários para exibir erros informativos na interface.

A tela TelaCardapio deve usar FutureBuilder<List<Produto>> para orquestrar o carregamento. O Future a ser observado deve ser obtido chamando ProdutoRemoteDataSource.listarProdutos() uma única vez, armazenado em um campo da instância do State para evitar que o FutureBuilder reinicie a requisição a cada rebuilding do widget. Durante o carregamento (ConnectionState.waiting), a tela deve exibir um CircularProgressIndicator centralizado. Em caso de erro (snapshot.hasError), deve exibir uma mensagem de erro legível e um botão “Tentar novamente” que force a reconstrução do FutureBuilder com uma nova instância do Future. Quando os dados estiverem disponíveis, deve exibir os produtos em um ListView.builder, com cada produto representado por um ListTile mostrando o nome em title, a categoria em subtitle e o preço formatado como R$ X,XX em trailing. O http.Client e o token devem ser recebidos pelo construtor da tela, nunca instanciados dentro dela.

Uma restrição que não pode ser contornada: você não deve usar http.get(), http.post() ou qualquer outro método estático do pacote http. Toda comunicação deve passar pela instância de http.Client recebida no construtor. Essa restrição não é arbitrária: ela é o que torna a classe testável, pois em testes de unidade você pode injetar um cliente simulado que retorna respostas controladas sem fazer nenhuma conexão de rede real. Outro ponto obrigatório: use sempre jsonDecode do dart:convert para desserializar o corpo da resposta, e faça isso somente após verificar que o statusCode é 200.

Antes de começar, reflita sobre estas questões: por que Uri.https('api.delivery.example.com', '/produtos') é preferível a Uri.parse('https://api.delivery.example.com/produtos')? O que aconteceria se você chamasse jsonDecode(response.body) em uma resposta 404 sem verificar o statusCode primeiro — o código quebraria? O resultado seria silencioso? Por que o http.Client é recebido por construtor em vez de ser instanciado dentro do ProdutoRemoteDataSource com http.Client()?

O campo preco do produto pode chegar do servidor como inteiro (29) ou como ponto flutuante (29.90), dependendo do produto. O cast json['preco'] as double funcionará para 29.90, mas lançará uma TypeError em tempo de execução para 29, porque 29 é do tipo int em Dart e não pode ser promovido diretamente para double. A expressão correta é (json['preco'] as num).toDouble(), que funciona para ambos os casos. Esse é um dos bugs mais comuns em serialização JSON com Dart e quase sempre só aparece em produção.

O que deve ser entregue: um único arquivo chamado g_ex1.dart, onde g é o nome do seu grupo. O arquivo deve conter a classe ProdutoRemoteDataSource, o modelo Produto com fromJson, as exceções NotFoundException e ApiException, a tela TelaCardapio com FutureBuilder e uma função main que execute o aplicativo com dados de exemplo.


Exercício 2

PedidoNotifier: POST, PATCH e tratamento completo de erros

Escrever dados em uma API exige mais do que uma leitura: exige serialização correta, tratamento de respostas sem corpo e garantia de que o estado da UI nunca fique inconsistente

Buscar dados de uma API é relativamente simples: você faz a requisição, recebe o JSON e desserializa. Escrever dados é mais delicado. Uma requisição POST para criar um pedido envolve serializar corretamente os dados de entrada, configurar os cabeçalhos certos (Content-Type: application/json e Accept: application/json), lidar com um código 201 em vez de 200 como indicador de sucesso, tratar um 401 com uma mensagem significativa para o usuário, reconhecer um 422 como indicador de que um produto foi removido do cardápio, e garantir que, independente de qualquer caminho de execução — sucesso, erro esperado, exceção de rede —, o estado de carregamento da UI seja sempre restaurado ao final. Uma requisição PATCH para atualizar o status de um pedido adiciona outra nuance: o servidor retorna 204 (No Content), o que significa que o corpo da resposta é vazio e tentar aplicar jsonDecode nele resultará em erro.

Este exercício pede que você implemente, no arquivo g_ex2.dart, um sistema completo para criação e atualização de pedidos, integrando diretamente o pacote http com o Provider.

O núcleo do exercício é a classe PedidoNotifier, que deve estender ChangeNotifier. Ela gerencia três estados internos: bool _carregando, String? _erro e Pedido? _pedidoCriado. Os três devem ter getters públicos correspondentes. A classe deve receber um http.Client client e um String token no construtor.

O método Future<void> criarPedido(String usuarioId, List<({int produtoId, int quantidade})> itens) é o mais complexo do exercício. Ele deve iniciar definindo _carregando = true e chamando notifyListeners(). Em seguida, deve montar o corpo da requisição como um mapa Dart e serializá-lo com jsonEncode, depois enviar um POST para https://api.delivery.example.com/pedidos com os cabeçalhos Content-Type: application/json, Accept: application/json e Authorization: Bearer {token}, com um timeout de 15 segundos. O tratamento de respostas deve seguir esta lógica: código 201 significa que o pedido foi criado — desserialize o corpo e salve o resultado em _pedidoCriado e limpe _erro; código 401 significa que a sessão expirou — defina _erro com uma mensagem informativa para o usuário e limpe _pedidoCriado; código 422 significa que algum produto do pedido está indisponível — defina _erro com a mensagem correspondente; qualquer outro código deve resultar em uma ApiException capturada logo abaixo. Todo esse bloco deve estar envolto em try/catch, capturando TimeoutException com a mensagem “O servidor demorou demais para responder. Tente novamente.” e SocketException com a mensagem “Sem conexão com a internet. Verifique sua rede.”. Independente de qual caminho o código seguir, _carregando deve ser definido como false ao final — use um bloco finally para garantir isso — e notifyListeners() deve ser chamado.

A lista de itens usa records do Dart 3: List<({int produtoId, int quantidade})>. Ao serializar cada item para JSON, acesse os campos com a sintaxe de named fields: item.produtoId e item.quantidade.

O método Future<void> atualizarStatus(int pedidoId, String novoStatus) deve fazer um PATCH para /pedidos/{pedidoId}/status com o corpo {'status': novoStatus}. Um retorno 204 indica sucesso — e este é o ponto de atenção: o código 204 significa que o servidor processou a requisição com sucesso mas não há corpo na resposta. Você não deve chamar jsonDecode(response.body) neste caso, pois response.body será uma string vazia, e jsonDecode de uma string vazia lança uma FormatException. Após um 204 bem-sucedido, se _pedidoCriado não for nulo e o id corresponder, atualize o campo status criando uma nova instância de Pedido com copyWith. Um retorno 404 deve definir _erro com uma mensagem indicando que o pedido não foi encontrado.

A tela TelaRealizarPedido deve usar Consumer<PedidoNotifier> para reagir às mudanças de estado. Quando notifier.carregando for true, exiba um indicador de carregamento sobreposto à tela usando Stack com um CircularProgressIndicator centralizado. Quando notifier.erro não for nulo, exiba o erro em um SnackBar — e este é o segundo ponto de atenção: SnackBar não pode ser exibido dentro do método build, pois naquele momento o frame ainda não foi finalizado. Use WidgetsBinding.instance.addPostFrameCallback((_) { ... }) para programar a exibição do SnackBar para depois que o frame atual for renderizado e, após exibi-lo, limpe o erro no notifier. Quando notifier.pedidoCriado não for nulo, exiba o número do pedido e o valor total formatado. A tela deve ter um botão “Fazer Pedido” que chama criarPedido com dados de exemplo.

O modelo Pedido deve ter os campos id (int), usuarioId (String), status (String), valorTotal (double) e criadoEm (DateTime), além de uma lista de ItemPedido. O modelo ItemPedido deve ter id, pedidoId, produtoId, nomeProduto, precoUnitario e quantidade. Ambos devem ter fromJson. O Pedido deve ter um método copyWith para permitir atualização imutável do status.

Três questões merecem reflexão antes de você começar. Primeira: por que o _carregando deve ser definido como false tanto no caso de sucesso quanto no caso de erro, e qual é a consequência para o usuário se você esquecer disso no caminho de erro? Segunda: o que acontece com a UI se você atualizar _erro mas esquecer de chamar notifyListeners() — o Consumer vai reagir? Terceira: por que o código 204 proíbe o uso de jsonDecode(response.body), e o que especificamente o padrão HTTP diz sobre respostas com esse código?

O SnackBar não pode ser exibido diretamente dentro do método build. Se você tentar fazer isso, o Flutter lançará um erro em tempo de execução informando que o ScaffoldMessenger não pode ser chamado durante a fase de build. A solução é usar WidgetsBinding.instance.addPostFrameCallback((_) { ScaffoldMessenger.of(context).showSnackBar(...); }), que agenda a exibição para logo após o frame atual ser concluído. Além disso, lembre-se de limpar o campo _erro no notifier após exibir o SnackBar — caso contrário, o mesmo erro será exibido novamente na próxima reconstrução do widget.

O que deve ser entregue: um único arquivo chamado g_ex2.dart, onde g é o nome do seu grupo. O arquivo deve conter as classes PedidoNotifier, Pedido, ItemPedido, ApiException, TelaRealizarPedido e uma função main que registre o PedidoNotifier com ChangeNotifierProvider e exiba a tela.


Exercício 3

HttpProdutoRepository com cache, interface de domínio e retry

A diferença entre um aplicativo que funciona em condições ideais e um que funciona no mundo real está na camada que você vai construir agora

Os dois exercícios anteriores implementaram a comunicação HTTP de forma direta, sem intermediários arquiteturais. Esse é o caminho natural para aprender, mas não é o caminho para produção. Em um aplicativo real baseado em arquitetura hexagonal e DDD, como o que você está desenvolvendo no Projeto Integrador, a lógica de comunicação HTTP não fica exposta diretamente na camada de apresentação nem no Provider. Ela fica encapsulada em uma implementação concreta de um repositório, que por sua vez implementa uma interface de domínio definida em Dart puro. O Provider depende apenas da interface, nunca da implementação. Isso permite trocar o backend, escrever testes com repositórios simulados e isolar completamente as camadas.

Este exercício pede que você implemente, no arquivo g_ex3.dart, a camada de repositório completa para o recurso de produtos, incluindo cache local com sqflite, retry com backoff exponencial, interface de domínio e integração com um notifier que usa sealed class para estados tipados.

O ponto de partida é a interface de domínio abstract class IProdutoRepository. Ela deve declarar três métodos: Future<List<Produto>> listarProdutos(), Future<Produto> buscarProduto(int id) e Future<void> atualizarDisponibilidade(int id, bool disponivel). Esta interface deve ser completamente independente de qualquer tecnologia: sem imports do pacote http, sem imports do sqflite, sem imports do Flutter. Dart puro. Isso é o que define a fronteira entre o domínio e a infraestrutura na arquitetura hexagonal.

A classe HttpProdutoRepository implements IProdutoRepository é a implementação concreta que vive na camada de infraestrutura. Ela recebe, no construtor, um http.Client client, uma String token e um Database db (do sqflite). O método listarProdutos() deve implementar a estratégia stale-while-revalidate: antes de fazer qualquer requisição de rede, consulte a tabela produtos no banco local. Se existirem registros cujo campo sincronizado_em (um inteiro com timestamp Unix em segundos) for mais recente do que 5 minutos atrás, retorne imediatamente os dados do cache sem fazer nenhuma chamada de rede. Se os dados estiverem ausentes ou desatualizados (stale), faça GET /produtos na API remota. Após receber a resposta com sucesso, salve os produtos no banco usando INSERT OR REPLACE com o timestamp atual e retorne a lista. Se a chamada remota falhar com SocketException ou TimeoutException e existir algum dado no cache (mesmo que stale), retorne o cache e lance uma WarningCacheException — uma exceção que você deve definir — para que o chamador saiba que os dados podem estar desatualizados. Se não existir cache e a chamada falhar, relance a exceção original.

O método buscarProduto(int id) deve buscar sempre direto da API, sem cache individual por produto. Trate 404 com NotFoundException.

O método atualizarDisponibilidade(int id, bool disponivel) deve fazer PATCH /produtos/{id} com o corpo {'disponivel': disponivel} e o cabeçalho correto. Após um retorno 204, atualize o campo disponivel do produto correspondente no banco local com um UPDATE.

O método privado Future<T> _comRetry<T>(Future<T> Function() operacao, {int tentativas = 3}) implementa retry com backoff exponencial. Ele deve executar operacao() e, se a chamada lançar ApiException com codigo >= 500 ou TimeoutException, aguardar um tempo de espera dado por Duration(seconds: pow(2, tentativaAtual).toInt()) — onde tentativaAtual começa em 0 — antes de tentar novamente. Após esgotar todas as tentativas sem sucesso, relance a última exceção capturada. Para falhas que não sejam ApiException com código 5xx nem TimeoutException, relance imediatamente sem retry. Os métodos listarProdutos() e buscarProduto() devem envolver suas chamadas de rede em _comRetry.

A hierarquia de estados da UI é implementada com sealed class CardapioState, que deve ter exatamente quatro subclasses: class CardapioCarregando extends CardapioState, class CardapioSucesso extends CardapioState (com campo List<Produto> produtos), class CardapioErro extends CardapioState (com campo String mensagem) e class CardapioAviso extends CardapioState (com campos List<Produto> produtos e String aviso). O estado CardapioAviso é retornado quando os dados vêm do cache stale — o usuário vê os produtos, mas com um aviso de que podem estar desatualizados.

A classe CardapioNotifier extends ChangeNotifier deve ter um campo CardapioState state com getter público e um método Future<void> carregarCardapio(). Ela deve receber IProdutoRepository repository no construtor — e não HttpProdutoRepository diretamente. Isso é inversão de dependência em ação. O método carregarCardapio() deve definir state = CardapioCarregando() e chamar notifyListeners(), depois chamar repository.listarProdutos(). Se a chamada retornar normalmente, defina state = CardapioSucesso(produtos: lista). Se lançar WarningCacheException, defina state = CardapioAviso(produtos: dadosDoCache, aviso: '...'). Se lançar qualquer outra exceção, defina state = CardapioErro(mensagem: '...'). Em todos os casos, chame notifyListeners().

A tela TelaCardapioCompleto deve usar Consumer<CardapioNotifier> e realizar um switch exaustivo sobre notifier.state, cobrindo todas as quatro variantes da sealed class. Quando o estado for CardapioCarregando, exiba CircularProgressIndicator. Quando for CardapioSucesso, exiba ListView com os produtos. Quando for CardapioAviso, exiba o ListView com os produtos e um MaterialBanner no topo informando que os dados podem estar desatualizados, com um botão “Atualizar” que chama carregarCardapio() novamente. Quando for CardapioErro, exiba a mensagem de erro e um botão de retry.

Três questões conduzem ao coração deste exercício. Primeira: por que CardapioNotifier deve receber IProdutoRepository e não HttpProdutoRepository — qual seria o problema concreto se ele recebesse a implementação diretamente? Segunda: o que o uso de IProdutoRepository como tipo do parâmetro do construtor permite fazer em testes de unidade que seria impossível com HttpProdutoRepository? Terceira: como o backoff exponencial — 1 segundo, 2 segundos, 4 segundos — protege o servidor de uma sobrecarga adicional durante uma falha transitória de infraestrutura?

Dois detalhes técnicos merecem atenção reforçada. O primeiro: pow(2, n) da biblioteca dart:math retorna num, não int. Se você passar o resultado diretamente para Duration(seconds: ...), obterá um erro de tipo. Use .toInt(). O segundo: o switch sobre CardapioState no método build da tela só será considerado exaustivo pelo compilador Dart se todas as quatro subclasses seladas forem cobertas. Se você omitir CardapioAviso, o Dart 3 emitirá um aviso indicando que o switch não é exaustivo, e o código pode lançar uma exceção em tempo de execução para esse caso.

O que deve ser entregue: um único arquivo chamado g_ex3.dart, onde g é o nome do seu grupo. O arquivo deve conter a interface IProdutoRepository, a classe HttpProdutoRepository com _comRetry, as exceções necessárias (NotFoundException, ApiException, WarningCacheException), a sealed class CardapioState com suas quatro subclasses, a classe CardapioNotifier, a tela TelaCardapioCompleto e uma função main com configuração mínima de DI usando GetIt.