sequenceDiagram
actor U as Usuário
participant W as Widget Flutter
participant N as CardapioNotifier
participant R as HttpProdutoRepository
participant H as http.Client
participant A as API REST
U->>W: toca em "Ver Cardápio"
W->>N: carregarProdutos()
N->>N: estado = carregando
N->>W: notifyListeners()
W->>W: exibe indicador de progresso
N->>R: listarProdutos()
R->>H: get(Uri.https(...))
H->>A: GET /produtos HTTP/1.1
A-->>H: 200 OK + JSON
H-->>R: Response(statusCode: 200, body: "[...]")
R->>R: jsonDecode + fromJson
R-->>N: List<Produto>
N->>N: estado = sucesso
N->>W: notifyListeners()
W->>U: exibe lista de produtos
Módulo 09 — Consumo de APIs REST
Você chegou a um dos módulos mais transformadores de toda a disciplina. Nos módulos anteriores, você construiu uma interface rica com Flutter, aprendeu a organizar a navegação com go_router, criou formulários com validação robusta, dominou o gerenciamento de estado com Provider e, no módulo passado, aprendeu a guardar dados no próprio dispositivo do usuário com SharedPreferences e sqflite. Tudo isso formou uma base sólida — mas o aplicativo de delivery ainda vivia em um mundo isolado, sem conversar com nenhum servidor externo. Os dados eram fixos no código ou armazenados apenas localmente. A partir de agora, isso muda completamente. Neste módulo, você vai aprender a conectar o seu aplicativo ao mundo real, consumindo APIs REST com o pacote http. O padrão Repository que você estudou no Módulo 08 para dados locais se estende de forma natural para dados remotos — a mesma interface de domínio que hoje aponta para o SQLite amanhã apontará para o servidor. Estude cada seção com cuidado, execute todos os exemplos usando os endpoints do aplicativo de delivery e chegue à aula presencial pronto para integrar o seu Projeto Integrador com o backend.
Seção 1 — HTTP e REST: a Linguagem da Web
Para consumir uma API REST com segurança e competência, você precisa entender, antes de qualquer linha de código, o protocolo que está por baixo de tudo: o HTTP. Muitos desenvolvedores pulam essa etapa e depois passam horas depurando problemas que teriam sido triviais com um entendimento sólido do protocolo. Não caia nessa armadilha.
O HTTP — Hypertext Transfer Protocol — é o protocolo de comunicação que define como mensagens são formatadas e transmitidas entre clientes e servidores na web. Ele foi criado por Tim Berners-Lee no início dos anos 1990 como parte do projeto World Wide Web e, desde então, evoluiu por múltiplas versões (HTTP/1.0, HTTP/1.1, HTTP/2, HTTP/3), mantendo sempre sua característica mais fundamental: o modelo cliente-servidor baseado em requisição e resposta.
No modelo cliente-servidor, existe sempre uma assimetria clara de papéis. O cliente — no nosso caso, o aplicativo Flutter rodando no celular do usuário — sempre toma a iniciativa de iniciar a comunicação. Ele formula uma pergunta (a requisição) e a envia ao servidor. O servidor — no nosso caso, a API REST em https://api.delivery.example.com — aguarda passivamente por requisições, processa cada uma delas e devolve uma resposta. Essa assimetria é fundamental: o servidor nunca envia dados espontaneamente ao cliente no HTTP tradicional (existem mecanismos como WebSockets e Server-Sent Events para comunicação bidirecional, mas eles estão fora do escopo deste módulo).
Uma das características mais importantes do HTTP é a ausência de estado, conhecida em inglês como statelessness. Isso significa que cada requisição HTTP é completamente independente das requisições anteriores. O servidor não guarda nenhuma memória do que aconteceu nas interações anteriores com aquele cliente. Se o usuário do seu aplicativo fez login dez segundos atrás, o servidor não se lembra disso ao receber a próxima requisição — é como se fosse a primeira vez que esse cliente se comunicasse com ele. Essa característica tem implicações profundas para o design de APIs: toda requisição deve conter toda a informação necessária para que o servidor a processe corretamente, incluindo as credenciais de autenticação, que você envia em cada requisição através do cabeçalho Authorization.
A Estrutura de uma Requisição HTTP
Uma requisição HTTP é composta por três partes principais: a linha de requisição, os cabeçalhos e, opcionalmente, o corpo. A linha de requisição contém o método HTTP (que define a ação desejada), a URL do recurso alvo e a versão do protocolo. Os cabeçalhos são pares chave-valor que fornecem metadados sobre a requisição — quem está fazendo, o formato dos dados enviados, o formato de resposta esperado, as credenciais de autenticação, entre outros. O corpo é o conteúdo da mensagem em si e é opcional: requisições GET normalmente não têm corpo, enquanto requisições POST e PUT quase sempre têm.
Uma requisição real para criar um pedido no aplicativo de delivery seria algo assim, em sua forma textual bruta:
POST /pedidos HTTP/1.1
Host: api.delivery.example.com
Content-Type: application/json
Accept: application/json
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
{
"usuario_id": "usr_abc123",
"itens": [
{ "produto_id": 1, "quantidade": 2 },
{ "produto_id": 5, "quantidade": 1 }
]
}
Cada linha acima tem um significado preciso que você aprenderá a construir programaticamente com o pacote http.
A Estrutura de uma Resposta HTTP
A resposta do servidor também tem três partes: a linha de status, os cabeçalhos de resposta e o corpo. A linha de status contém a versão do protocolo, o código de status numérico (que você estudará em detalhes na próxima seção) e uma frase de razão textual que descreve o código. Os cabeçalhos de resposta fornecem metadados sobre o servidor e sobre o conteúdo retornado — o formato dos dados, o tamanho do corpo, instruções de cache, entre outros. O corpo é o conteúdo da resposta propriamente dito, frequentemente um objeto JSON representando o recurso solicitado.
REST: Seis Princípios Arquiteturais
REST — Representational State Transfer — é um estilo arquitetural, não um protocolo. Foi definido por Roy Fielding em sua tese de doutorado de 2000 como um conjunto de princípios para o design de sistemas distribuídos escaláveis e flexíveis. Uma API é considerada “RESTful” quando adere a esses princípios.
O primeiro princípio é a interface uniforme. Todos os recursos são identificados por URIs, e as operações sobre eles são realizadas através de um conjunto padronizado de métodos (os verbos HTTP). Isso significa que, uma vez que você entende como interagir com um recurso, sabe como interagir com qualquer outro recurso da mesma API.
O segundo princípio é a ausência de estado (statelessness), que você já conhece. Cada requisição é auto-suficiente.
O terceiro princípio é o cache. As respostas do servidor devem indicar explicitamente se podem ser armazenadas em cache pelo cliente. Isso pode reduzir drasticamente o número de requisições ao servidor para dados que mudam raramente, como a lista de categorias do cardápio.
O quarto princípio é o sistema em camadas. O cliente não precisa saber se está se comunicando diretamente com o servidor de aplicação ou com um proxy, um balanceador de carga ou um gateway. Cada camada conhece apenas a camada imediatamente adjacente.
O quinto princípio é o cliente-servidor, que estabelece a separação de responsabilidades: o servidor se preocupa com a lógica de negócio e o armazenamento de dados, o cliente se preocupa com a apresentação e a experiência do usuário.
O sexto princípio, o único opcional, é o código sob demanda: o servidor pode, opcionalmente, enviar código executável ao cliente (como JavaScript). Esse princípio raramente é utilizado em APIs móveis e você pode ignorá-lo para fins práticos.
Recursos e URIs
Em REST, um recurso é qualquer entidade do seu domínio que pode ser identificada e manipulada individualmente. No aplicativo de delivery, os recursos são produtos, pedidos, itens_pedido, categorias, entre outros. Cada recurso é identificado por um URI — Uniform Resource Identifier — que segue uma estrutura hierárquica clara.
A convenção REST define que coleções de recursos usam substantivos no plural: /produtos identifica a coleção de todos os produtos. Recursos individuais são identificados pelo seu identificador dentro da coleção: /produtos/1 identifica o produto com id igual a 1. Recursos aninhados seguem a hierarquia da relação: /pedidos/42/itens identifica todos os itens do pedido 42. Filtros e buscas são expressos como parâmetros de query: /produtos?categoria=lanches filtra apenas os lanches.
O Fluxo Completo de uma Requisição REST
O diagrama a seguir mostra o ciclo completo de uma requisição REST, desde o momento em que o usuário interage com a interface do aplicativo até a exibição do resultado na tela:
Observe como a responsabilidade é distribuída entre camadas: o widget sabe apenas que precisa exibir dados e delegar ações ao notifier; o notifier gerencia o estado da UI e delega a busca de dados ao repositório; o repositório sabe como fazer requisições HTTP e converter JSON em entidades de domínio.
Seção 2 — Códigos de Status HTTP: o que o Servidor Quer Dizer
Os códigos de status HTTP são a linguagem primária com a qual o servidor comunica o resultado de cada requisição. Ler e tratar esses códigos corretamente é tão importante quanto fazer a requisição em si — ignorar um código 401 e tratar a resposta como se fosse 200 é um erro que vai comprometer a segurança e a usabilidade do seu aplicativo.
Os códigos de status são números de três dígitos agrupados em cinco classes, definidas pelo primeiro dígito. Os códigos 1xx são informativos e raramente vistos em APIs REST. Os códigos 2xx indicam sucesso. Os códigos 3xx indicam redirecionamento. Os códigos 4xx indicam erro do cliente — algo está errado na requisição que você enviou. Os códigos 5xx indicam erro do servidor — a requisição estava correta, mas algo falhou no lado do servidor.
A tabela a seguir detalha os códigos mais relevantes para o aplicativo de delivery e como você deve tratá-los na prática:
| Código | Nome | Significado para o aplicativo de delivery |
|---|---|---|
| 200 | OK | Requisição bem-sucedida; o corpo contém os dados solicitados |
| 201 | Created | Pedido criado com sucesso; o corpo contém o pedido com o id gerado |
| 204 | No Content | Status do pedido atualizado com sucesso; não há corpo de resposta |
| 400 | Bad Request | Os dados enviados são inválidos (campo obrigatório ausente, tipo errado) |
| 401 | Unauthorized | Token de autenticação ausente, expirado ou inválido — redirecionar para login |
| 403 | Forbidden | O usuário está autenticado, mas não tem permissão para esta operação |
| 404 | Not Found | O produto ou pedido solicitado não existe na base de dados |
| 409 | Conflict | Tentativa de criar um recurso que já existe (pedido duplicado) |
| 422 | Unprocessable Entity | Os dados são sintaticamente corretos, mas violam regras de negócio |
| 429 | Too Many Requests | O cliente excedeu o limite de requisições; aguardar antes de tentar novamente |
| 500 | Internal Server Error | Falha inesperada no servidor; tente novamente mais tarde |
O tratamento correto de cada categoria de código define a qualidade do seu aplicativo. Para os códigos 2xx, você deve processar o corpo da resposta e atualizar o estado da UI com os dados recebidos. Para o 204, deve confirmar a operação bem-sucedida sem tentar ler um corpo inexistente — tentar fazer jsonDecode em uma resposta 204 vai lançar uma FormatException.
Para os códigos 4xx, o comportamento varia significativamente por código. O 401 merece tratamento especial: ao recebê-lo, você deve limpar o token armazenado localmente, exibir uma mensagem informando ao usuário que sua sessão expirou e redirecioná-lo para a tela de login. Fazer isso em um único lugar — em uma camada de interceptação — é muito mais elegante do que verificar o 401 em cada repositório individualmente. O 404 indica que o recurso não existe e você deve tratar isso como um estado válido da aplicação, não como um erro catastrófico: exiba uma mensagem amigável como “Este produto não está mais disponível”. O 422 frequentemente contém no corpo da resposta uma lista de erros de validação de negócio que podem ser exibidos diretamente ao usuário.
Para os códigos 5xx, a estratégia mais adequada é o retry automático com backoff exponencial, que você estudará na Seção 15. Um erro 500 pode ser transitório — o servidor pode ter enfrentado um pico de carga momentâneo — e uma segunda tentativa poucos segundos depois frequentemente resolve o problema.
Atenção ao código 204. Quando o servidor retorna 204, o corpo da resposta é vazio por definição. Se você tentar chamar jsonDecode(response.body) em uma resposta 204, receberá uma FormatException. Sempre verifique response.statusCode antes de tentar interpretar o corpo.
Seção 3 — O Pacote http no Flutter
O pacote http é a solução oficial e mais adotada para requisições HTTP em projetos Flutter. Ele fornece uma API simples e bem testada que abstrai os detalhes de baixo nível do protocolo, permitindo que você se concentre na lógica da aplicação. Para o Projeto Integrador, essa é a biblioteca que conectará o aplicativo ao backend.
Para adicionar o pacote ao seu projeto, inclua a dependência no arquivo pubspec.yaml:
Após editar o pubspec.yaml, execute flutter pub get no terminal integrado do VS Code para que o Flutter baixe o pacote. Em seguida, importe-o nos arquivos Dart onde você precisar fazer requisições:
O alias as http é uma convenção quase universal na comunidade Flutter. Ele evita conflitos de nome — o pacote expõe um tipo Response que poderia colidir com outros tipos de mesmo nome em projetos mais complexos — e torna o código mais legível, deixando explícito que http.get() é uma função do pacote, não uma função local.
A Classe http.Client e os Métodos Estáticos de Conveniência
O pacote http oferece duas formas de fazer requisições. A primeira é através de métodos estáticos de conveniência como http.get(), http.post(), http.put() e http.delete(). Esses métodos criam um cliente HTTP temporário internamente, fazem a requisição e fecham o cliente automaticamente. São adequados para casos simples ou para quando você faz poucas requisições isoladas.
A segunda forma é instanciando um objeto http.Client e usando seus métodos de instância. Essa abordagem é preferível quando você faz múltiplas requisições em sequência, pois o cliente reutiliza a conexão TCP subjacente — o que é significativamente mais eficiente do que abrir e fechar uma nova conexão para cada requisição. Além disso, um http.Client instanciado pode ser substituído por um mock nos testes automatizados, o que é uma vantagem enorme para a testabilidade do código.
No contexto do Projeto Integrador com arquitetura hexagonal, a abordagem recomendada é injetar um http.Client no repositório via construtor, registrando-o no GetIt como um singleton. Isso permite que todos os repositórios compartilhem o mesmo cliente — reutilizando conexões — e facilita a substituição por um mock durante os testes.
Importante: quando você usa um http.Client instanciado, é sua responsabilidade chamar client.close() quando ele não for mais necessário, para liberar os recursos de rede. Se o cliente for um singleton gerenciado pelo GetIt, feche-o apenas quando o aplicativo for encerrado.
O diagrama a seguir mostra o fluxo que você percorrerá em código ao fazer qualquer requisição com o pacote http:
flowchart TD
A["Construir URI\n(Uri.https / Uri.parse)"] --> B["Montar cabeçalhos\n(Content-Type, Authorization)"]
B --> C["Enviar requisição\n(client.get / post / put / patch / delete)"]
C --> D["Receber Response\n(statusCode + body)"]
D --> E{"statusCode\n>= 200 e < 300?"}
E -- Sim --> F["jsonDecode(response.body)"]
F --> G["Converter em modelo\n(fromJson)"]
G --> H["Retornar ao Notifier"]
E -- Não --> I{"statusCode\n== 401?"}
I -- Sim --> J["Lançar UnauthorizedException"]
I -- Não --> K{"statusCode\n== 404?"}
K -- Sim --> L["Lançar NotFoundException"]
K -- Não --> M["Lançar ApiException\ncom código e mensagem"]
Seção 4 — Construindo URIs Corretamente
A construção correta de URIs é um detalhe técnico que parece trivial mas causa muitos bugs silenciosos em produção. O problema mais comum é a concatenação ingênua de strings para montar URLs, que falha silenciosamente quando os parâmetros contêm caracteres especiais como espaços, acentos, barras ou o sinal de porcentagem.
Imagine que o usuário do aplicativo de delivery pesquisa pelo termo “frango & batata”. Se você construir a URL concatenando strings: 'https://api.delivery.example.com/produtos?nome=frango & batata', o servidor pode receber uma URL malformada, pois o espaço e o & têm significados especiais em URIs. O & é o separador de parâmetros de query, então o servidor interpretaria isso como dois parâmetros: nome=frango e batata= (sem valor). O resultado é um bug completamente silencioso que só se manifesta com dados específicos — exatamente o tipo mais difícil de encontrar.
A solução correta é sempre usar as classes Uri da biblioteca padrão do Dart, que realizam o percent-encoding automaticamente — isso é, a conversão de caracteres especiais para sua representação segura em URLs (%20 para espaço, %26 para &, e assim por diante).
O Dart oferece principalmente dois construtores para criar URIs de forma segura. O Uri.parse() é adequado quando você tem uma URL completa como string e quer convertê-la em um objeto Uri. Já o Uri.https() (e seu equivalente Uri.http() para desenvolvimento local) constrói a URI a partir de componentes separados: o host, o caminho e os parâmetros de query. Essa segunda abordagem é preferível para URIs dinâmicas, pois cada componente é codificado independentemente.
O construtor Uri.https(String host, String unencodedPath, [Map<String, String>? queryParameters]) recebe três argumentos: o host do servidor (sem o esquema https://), o caminho do recurso e um mapa opcional de parâmetros de query. O Dart cuida automaticamente da codificação de todos os valores.
import 'package:http/http.dart' as http;
// Constante com o host da API — nunca embutir no meio do código
const String _host = 'api.delivery.example.com';
// URI para listar todos os produtos
// Resulta em: https://api.delivery.example.com/produtos
Uri uriListarProdutos() {
return Uri.https(_host, '/produtos');
}
// URI para filtrar produtos por categoria
// Resulta em: https://api.delivery.example.com/produtos?categoria=lanches
Uri uriFiltrarPorCategoria(String categoria) {
return Uri.https(_host, '/produtos', {'categoria': categoria});
}
// URI para buscar um produto específico por ID
// Resulta em: https://api.delivery.example.com/produtos/42
Uri uriBuscarProduto(int id) {
// pathSegments monta o caminho como lista de segmentos — cada um é codificado
return Uri.https(_host, '/produtos/$id');
}
// URI para buscar pedidos de um usuário
// Resulta em: https://api.delivery.example.com/pedidos?usuario_id=usr_abc123
Uri uriBuscarPedidosDoUsuario(String usuarioId) {
return Uri.https(_host, '/pedidos', {'usuario_id': usuarioId});
}
// URI para acessar um pedido específico
// Resulta em: https://api.delivery.example.com/pedidos/7
Uri uriBuscarPedido(int pedidoId) {
return Uri.https(_host, '/pedidos/$pedidoId');
}
// URI para atualizar o status de um pedido
// Resulta em: https://api.delivery.example.com/pedidos/7/status
Uri uriAtualizarStatusPedido(int pedidoId) {
return Uri.https(_host, '/pedidos/$pedidoId/status');
}abstract final class ApiUris {
static const _host = 'api.delivery.example.com';
static Uri produtos([Map<String, String>? query]) =>
Uri.https(_host, '/produtos', query);
static Uri produto(int id) => Uri.https(_host, '/produtos/$id');
static Uri produtoDisponibilidade(int id) =>
Uri.https(_host, '/produtos/$id');
static Uri pedidos([Map<String, String>? query]) =>
Uri.https(_host, '/pedidos', query);
static Uri pedido(int id) => Uri.https(_host, '/pedidos/$id');
static Uri statusPedido(int id) => Uri.https(_host, '/pedidos/$id/status');
}Observe no código otimizado o uso de abstract final class — um padrão Dart para criar classes que funcionam puramente como namespaces de constantes e funções estáticas, sem permitir instanciação ou herança. Ao concentrar todas as URIs em uma única classe ApiUris, você tem um único ponto de controle para o endereçamento da API. Se o host mudar, você altera apenas um lugar.
Seção 5 — GET: Buscando Dados do Servidor
O método GET é o mais utilizado em qualquer API REST. Ele solicita a representação de um recurso sem modificar nenhum dado no servidor — é uma operação completamente segura e idempotente. No aplicativo de delivery, você usará GET para carregar o cardápio, buscar detalhes de um produto e consultar o histórico de pedidos do usuário.
Uma requisição GET é composta apenas pela URI e pelos cabeçalhos — sem corpo. O servidor responde com o recurso solicitado serializado em JSON no corpo da resposta. O tratamento correto de uma requisição GET envolve cinco etapas sequenciais: construir a URI, enviar a requisição com cabeçalhos adequados, verificar o código de status da resposta, decodificar o corpo JSON e converter os dados em objetos de domínio.
O timeout é um aspecto fundamental que muitos desenvolvedores esquecem ao implementar requisições HTTP. Sem um timeout configurado, uma requisição pode ficar pendente indefinidamente — por exemplo, se o servidor estiver sobrecarregado e não responder. O método .timeout(Duration(seconds: 10)) encadeia ao Future retornado pelo http.Client e lança uma TimeoutException se a resposta não chegar dentro do prazo especificado. Dez segundos é um valor razoável para a maioria das situações de rede móvel, mas você pode ajustar conforme o comportamento observado da sua API.
Exemplo 1: Buscando a Lista de Produtos do Cardápio
import 'dart:convert';
import 'dart:io';
import 'package:http/http.dart' as http;
// Supõe que ApiUris e as classes de modelo já existem
Future<List<Produto>> listarProdutos(http.Client client) async {
// Monta a URI corretamente
final uri = ApiUris.produtos();
// Monta os cabeçalhos da requisição
final cabecalhos = {
'Accept': 'application/json',
'Authorization': 'Bearer $tokenAtual', // tokenAtual vem do estado de autenticação
};
// Envia a requisição com timeout de 10 segundos
final response = await client
.get(uri, headers: cabecalhos)
.timeout(const Duration(seconds: 10));
// Verifica se a resposta foi bem-sucedida
if (response.statusCode == 200) {
// Decodifica o corpo JSON — espera uma lista
final List<dynamic> jsonList = jsonDecode(response.body) as List<dynamic>;
// Converte cada item da lista em um objeto Produto
final produtos = jsonList
.map((item) => Produto.fromJson(item as Map<String, dynamic>))
.toList();
return produtos;
} else {
// Código de status inesperado — lança exceção com detalhes
throw ApiException(
codigo: response.statusCode,
mensagem: 'Erro ao listar produtos: ${response.body}',
);
}
}import 'dart:convert';
import 'package:http/http.dart' as http;
Future<List<Produto>> listarProdutos(http.Client client, String token) async {
final response = await client
.get(ApiUris.produtos(), headers: _cabecalhos(token))
.timeout(const Duration(seconds: 10));
_verificarStatus(response);
return (jsonDecode(response.body) as List)
.cast<Map<String, dynamic>>()
.map(Produto.fromJson)
.toList();
}
Map<String, String> _cabecalhos(String token) => {
'Accept': 'application/json',
'Authorization': 'Bearer $token',
};
void _verificarStatus(http.Response response) {
if (response.statusCode < 200 || response.statusCode >= 300) {
_lancarExcecao(response);
}
}
Never _lancarExcecao(http.Response response) => switch (response.statusCode) {
401 => throw UnauthorizedException(),
403 => throw ForbiddenException(),
404 => throw NotFoundException(response.body),
422 => throw UnprocessableException(response.body),
500 => throw ServerException(),
_ => throw ApiException(response.statusCode, response.body),
};Exemplo 2: Buscando um Produto por ID
Future<Produto> buscarProduto(http.Client client, String token, int id) async {
// URI aponta para o produto específico: /produtos/42
final uri = ApiUris.produto(id);
final response = await client
.get(uri, headers: {'Accept': 'application/json', 'Authorization': 'Bearer $token'})
.timeout(const Duration(seconds: 10));
if (response.statusCode == 200) {
// O corpo é um objeto único, não uma lista
final Map<String, dynamic> json = jsonDecode(response.body) as Map<String, dynamic>;
return Produto.fromJson(json);
} else if (response.statusCode == 404) {
// Produto não encontrado — exceção de domínio específica
throw NotFoundException('Produto com id $id não encontrado.');
} else {
throw ApiException(codigo: response.statusCode, mensagem: response.body);
}
}Future<Produto> buscarProduto(http.Client client, String token, int id) async {
final response = await client
.get(ApiUris.produto(id), headers: _cabecalhos(token))
.timeout(const Duration(seconds: 10));
_verificarStatus(response);
return Produto.fromJson(jsonDecode(response.body) as Map<String, dynamic>);
}Exemplo 3: Buscando Pedidos do Usuário com Query Parameter
Future<List<Pedido>> listarPedidosDoUsuario(
http.Client client,
String token,
String usuarioId,
) async {
// A URI inclui o parâmetro de query: /pedidos?usuario_id=usr_abc123
final uri = ApiUris.pedidos({'usuario_id': usuarioId});
final response = await client
.get(uri, headers: {'Accept': 'application/json', 'Authorization': 'Bearer $token'})
.timeout(const Duration(seconds: 15));
if (response.statusCode == 200) {
final List<dynamic> jsonList = jsonDecode(response.body) as List<dynamic>;
return jsonList
.map((item) => Pedido.fromJson(item as Map<String, dynamic>))
.toList();
} else {
throw ApiException(codigo: response.statusCode, mensagem: response.body);
}
}Future<List<Pedido>> listarPedidosDoUsuario(
http.Client client,
String token,
String usuarioId,
) async {
final response = await client
.get(ApiUris.pedidos({'usuario_id': usuarioId}), headers: _cabecalhos(token))
.timeout(const Duration(seconds: 15));
_verificarStatus(response);
return (jsonDecode(response.body) as List)
.cast<Map<String, dynamic>>()
.map(Pedido.fromJson)
.toList();
}Seção 6 — Serialização e Desserialização de JSON
JSON — JavaScript Object Notation — é o formato de troca de dados mais utilizado em APIs REST modernas. Ele é legível por humanos, simples de produzir e consumir por máquinas e suportado nativamente por praticamente toda linguagem de programação. Entender como converter objetos Dart em JSON e vice-versa é uma habilidade indispensável para qualquer desenvolvedor Flutter.
O Dart oferece duas funções no pacote dart:convert para trabalhar com JSON: jsonDecode() e jsonEncode(). O jsonDecode() recebe uma String contendo um documento JSON e retorna um objeto Dart equivalente: um Map<String, dynamic> se o JSON for um objeto, uma List<dynamic> se o JSON for um array, ou um tipo primitivo (String, int, double, bool, null) se o JSON for um valor simples. O jsonEncode() faz o caminho inverso: recebe um objeto Dart — tipicamente um Map ou uma List — e retorna a String JSON correspondente.
O tipo de retorno dynamic do jsonDecode é uma fonte frequente de erros em tempo de execução. Quando você acessa json['preco'] e o valor é um num no JSON (que pode ser int ou double), um cast direto as double vai falhar se o valor for representado como 29 em vez de 29.0 — o JSON não distingue obrigatoriamente entre inteiros e decimais. A forma correta é usar (json['preco'] as num).toDouble(), que funciona independentemente de o valor ter casas decimais ou não.
Classes de Modelo com fromJson e toJson
A convenção em Flutter para serialização manual é criar classes de modelo com dois elementos: um factory constructor fromJson(Map<String, dynamic> json) que constrói uma instância a partir de um mapa e um método de instância toJson() que retorna um mapa representando o objeto. Essa convenção é consistente com o que geradores de código como json_serializable produzem, facilitando uma eventual migração para geração automática.
import 'dart:convert';
class Produto {
final int id;
final String nome;
final String categoria;
final double preco;
final bool disponivel;
final String descricao;
const Produto({
required this.id,
required this.nome,
required this.categoria,
required this.preco,
required this.disponivel,
required this.descricao,
});
// Constrói um Produto a partir de um mapa JSON
factory Produto.fromJson(Map<String, dynamic> json) {
return Produto(
id: json['id'] as int,
nome: json['nome'] as String,
categoria: json['categoria'] as String,
// (num).toDouble() para aceitar tanto 29 quanto 29.90 no JSON
preco: (json['preco'] as num).toDouble(),
disponivel: json['disponivel'] as bool,
descricao: json['descricao'] as String,
);
}
// Converte o Produto em um mapa para envio à API
Map<String, dynamic> toJson() {
return {
'id': id,
'nome': nome,
'categoria': categoria,
'preco': preco,
'disponivel': disponivel,
'descricao': descricao,
};
}
// Serializa diretamente para String JSON
String toJsonString() => jsonEncode(toJson());
@override
String toString() => 'Produto(id: $id, nome: $nome, preco: $preco)';
}
class ItemPedido {
final int id;
final int pedidoId;
final int produtoId;
final String nomeProduto;
final double precoUnitario;
final int quantidade;
const ItemPedido({
required this.id,
required this.pedidoId,
required this.produtoId,
required this.nomeProduto,
required this.precoUnitario,
required this.quantidade,
});
factory ItemPedido.fromJson(Map<String, dynamic> json) {
return ItemPedido(
id: json['id'] as int,
pedidoId: json['pedido_id'] as int,
produtoId: json['produto_id'] as int,
nomeProduto: json['nome_produto'] as String,
precoUnitario: (json['preco_unitario'] as num).toDouble(),
quantidade: json['quantidade'] as int,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'pedido_id': pedidoId,
'produto_id': produtoId,
'nome_produto': nomeProduto,
'preco_unitario': precoUnitario,
'quantidade': quantidade,
};
}
}
class Pedido {
final int id;
final String usuarioId;
final String status;
final double valorTotal;
final DateTime criadoEm;
final List<ItemPedido> itens;
const Pedido({
required this.id,
required this.usuarioId,
required this.status,
required this.valorTotal,
required this.criadoEm,
required this.itens,
});
factory Pedido.fromJson(Map<String, dynamic> json) {
// A lista de itens pode vir como lista de mapas ou como lista vazia
final List<dynamic> itensJson = json['itens'] as List<dynamic>? ?? [];
return Pedido(
id: json['id'] as int,
usuarioId: json['usuario_id'] as String,
status: json['status'] as String,
valorTotal: (json['valor_total'] as num).toDouble(),
// DateTime vem como string ISO 8601 da API
criadoEm: DateTime.parse(json['criado_em'] as String),
itens: itensJson
.map((item) => ItemPedido.fromJson(item as Map<String, dynamic>))
.toList(),
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'usuario_id': usuarioId,
'status': status,
'valor_total': valorTotal,
'criado_em': criadoEm.toIso8601String(),
'itens': itens.map((item) => item.toJson()).toList(),
};
}
}import 'dart:convert';
final class Produto {
const Produto({
required this.id,
required this.nome,
required this.categoria,
required this.preco,
required this.disponivel,
required this.descricao,
});
factory Produto.fromJson(Map<String, dynamic> j) => Produto(
id: j['id'] as int,
nome: j['nome'] as String,
categoria: j['categoria'] as String,
preco: (j['preco'] as num).toDouble(),
disponivel: j['disponivel'] as bool,
descricao: j['descricao'] as String,
);
final int id;
final String nome;
final String categoria;
final double preco;
final bool disponivel;
final String descricao;
Map<String, dynamic> toJson() => {
'id': id,
'nome': nome,
'categoria': categoria,
'preco': preco,
'disponivel': disponivel,
'descricao': descricao,
};
@override
String toString() => 'Produto($id, $nome, R\$$preco)';
}
final class ItemPedido {
const ItemPedido({
required this.id,
required this.pedidoId,
required this.produtoId,
required this.nomeProduto,
required this.precoUnitario,
required this.quantidade,
});
factory ItemPedido.fromJson(Map<String, dynamic> j) => ItemPedido(
id: j['id'] as int,
pedidoId: j['pedido_id'] as int,
produtoId: j['produto_id'] as int,
nomeProduto: j['nome_produto'] as String,
precoUnitario: (j['preco_unitario'] as num).toDouble(),
quantidade: j['quantidade'] as int,
);
final int id;
final int pedidoId;
final int produtoId;
final String nomeProduto;
final double precoUnitario;
final int quantidade;
Map<String, dynamic> toJson() => {
'id': id,
'pedido_id': pedidoId,
'produto_id': produtoId,
'nome_produto': nomeProduto,
'preco_unitario': precoUnitario,
'quantidade': quantidade,
};
}
final class Pedido {
const Pedido({
required this.id,
required this.usuarioId,
required this.status,
required this.valorTotal,
required this.criadoEm,
required this.itens,
});
factory Pedido.fromJson(Map<String, dynamic> j) => Pedido(
id: j['id'] as int,
usuarioId: j['usuario_id'] as String,
status: j['status'] as String,
valorTotal: (j['valor_total'] as num).toDouble(),
criadoEm: DateTime.parse(j['criado_em'] as String),
itens: (j['itens'] as List? ?? [])
.cast<Map<String, dynamic>>()
.map(ItemPedido.fromJson)
.toList(),
);
final int id;
final String usuarioId;
final String status;
final double valorTotal;
final DateTime criadoEm;
final List<ItemPedido> itens;
Map<String, dynamic> toJson() => {
'id': id,
'usuario_id': usuarioId,
'status': status,
'valor_total': valorTotal,
'criado_em': criadoEm.toIso8601String(),
'itens': itens.map((i) => i.toJson()).toList(),
};
}Observe o tratamento cuidadoso de DateTime: a API retorna datas no formato ISO 8601 como string ("2024-03-09T14:30:00.000Z"), e DateTime.parse() converte essa string em um objeto DateTime do Dart. No caminho inverso, .toIso8601String() produz a string no formato esperado pela maioria das APIs.
Seção 7 — POST: Enviando Dados ao Servidor
O método POST é usado para criar novos recursos no servidor. Diferente do GET, ele tem um corpo na requisição contendo os dados do novo recurso a ser criado. A resposta bem-sucedida a um POST é o código 201 (Created), e o corpo da resposta tipicamente contém o recurso recém-criado, incluindo o identificador gerado pelo servidor — informação que o cliente não tinha antes de fazer a requisição.
A distinção entre POST, PUT e PATCH é fundamental para usar a API corretamente. POST cria um novo recurso e o id é gerado pelo servidor. PUT substitui um recurso existente em sua totalidade — você envia todos os campos, e o servidor substitui o que havia antes. PATCH atualiza apenas os campos especificados — você envia somente os campos que deseja modificar, e o servidor mantém o restante intacto.
Ao enviar dados com POST ou PUT, você deve configurar dois cabeçalhos: Content-Type: application/json informa ao servidor o formato do corpo que você está enviando, e Accept: application/json informa o formato de resposta que você espera receber. Sem o Content-Type, o servidor pode não conseguir interpretar o corpo da requisição corretamente.
Criando um Novo Pedido
import 'dart:convert';
import 'package:http/http.dart' as http;
Future<Pedido> criarPedido(
http.Client client,
String token,
String usuarioId,
List<Map<String, dynamic>> itens,
) async {
// Monta o corpo da requisição como mapa Dart
final corpo = {
'usuario_id': usuarioId,
'itens': itens, // lista de {'produto_id': int, 'quantidade': int}
};
// Serializa o mapa para String JSON
final corpoJson = jsonEncode(corpo);
// Cabeçalhos incluindo Content-Type — essencial para POST com corpo JSON
final cabecalhos = {
'Content-Type': 'application/json',
'Accept': 'application/json',
'Authorization': 'Bearer $token',
};
// Envia a requisição POST
final response = await client
.post(
ApiUris.pedidos(),
headers: cabecalhos,
body: corpoJson,
)
.timeout(const Duration(seconds: 15));
// POST bem-sucedido retorna 201 Created
if (response.statusCode == 201) {
final Map<String, dynamic> jsonResposta =
jsonDecode(response.body) as Map<String, dynamic>;
// O pedido retornado já tem o id gerado pelo servidor
return Pedido.fromJson(jsonResposta);
} else if (response.statusCode == 422) {
// Produto indisponível ou regra de negócio violada
throw UnprocessableException(response.body);
} else {
throw ApiException(codigo: response.statusCode, mensagem: response.body);
}
}import 'dart:convert';
import 'package:http/http.dart' as http;
Future<Pedido> criarPedido(
http.Client client,
String token,
String usuarioId,
List<({int produtoId, int quantidade})> itens,
) async {
final response = await client
.post(
ApiUris.pedidos(),
headers: _cabecalhosJson(token),
body: jsonEncode({
'usuario_id': usuarioId,
'itens': [
for (final item in itens)
{'produto_id': item.produtoId, 'quantidade': item.quantidade},
],
}),
)
.timeout(const Duration(seconds: 15));
_verificarStatus(response);
return Pedido.fromJson(jsonDecode(response.body) as Map<String, dynamic>);
}
Map<String, String> _cabecalhosJson(String token) => {
'Content-Type': 'application/json',
'Accept': 'application/json',
'Authorization': 'Bearer $token',
};Note a elegância do uso de records (({int produtoId, int quantidade})) na versão otimizada — um recurso moderno do Dart 3 que permite criar tipos de dados simples inline sem precisar definir uma classe completa.
Seção 8 — PUT e PATCH: Atualizando Recursos
A distinção entre PUT e PATCH é um dos pontos que mais gera confusão entre desenvolvedores que estão aprendendo a consumir APIs REST. Entender a semântica correta de cada método evita bugs sutis e garante que o seu código use a API da forma que o servidor espera.
O método PUT tem semântica de substituição total. Quando você faz PUT /produtos/1 com um corpo JSON, está dizendo ao servidor: “substitua o produto de id 1 inteiramente pelo que estou enviando agora”. Isso significa que se você omitir um campo no corpo, o servidor pode interpretar essa omissão como “esse campo deve ser limpo ou redefinido para seu valor padrão”. Por isso, ao usar PUT, você deve sempre enviar todos os campos da entidade, mesmo os que não mudaram.
O método PATCH tem semântica de atualização parcial. Quando você faz PATCH /pedidos/7/status com {'status': 'em_preparo'}, está dizendo ao servidor: “atualize apenas o campo status do pedido 7 para o valor que estou enviando; mantenha todos os outros campos intactos”. Isso é ideal para operações de atualização que afetam apenas um subconjunto dos campos, como atualizar a disponibilidade de um produto ou mudar o status de um pedido ao longo do fluxo de entrega.
PATCH: Atualizando a Disponibilidade de um Produto
// PATCH /produtos/42 com body {'disponivel': false}
// Atualiza SOMENTE o campo disponivel, preservando nome, preco, categoria, etc.
Future<void> atualizarDisponibilidadeProduto(
http.Client client,
String token,
int produtoId,
bool disponivel,
) async {
final uri = ApiUris.produto(produtoId);
// Corpo contém APENAS o campo que está sendo alterado
final corpo = jsonEncode({'disponivel': disponivel});
final response = await client
.patch(
uri,
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'Authorization': 'Bearer $token',
},
body: corpo,
)
.timeout(const Duration(seconds: 10));
// A API retorna 200 com o produto atualizado, ou 204 sem corpo
if (response.statusCode == 200 || response.statusCode == 204) {
return; // Operação bem-sucedida
} else if (response.statusCode == 404) {
throw NotFoundException('Produto $produtoId não encontrado.');
} else {
throw ApiException(codigo: response.statusCode, mensagem: response.body);
}
}Future<void> atualizarDisponibilidadeProduto(
http.Client client,
String token,
int produtoId,
bool disponivel,
) async {
final response = await client
.patch(
ApiUris.produto(produtoId),
headers: _cabecalhosJson(token),
body: jsonEncode({'disponivel': disponivel}),
)
.timeout(const Duration(seconds: 10));
if (response.statusCode != 200 && response.statusCode != 204) {
_lancarExcecao(response);
}
}PUT: Atualizando um Produto Completo
// PUT /produtos/42 — substitui o produto inteiro
Future<Produto> atualizarProdutoCompleto(
http.Client client,
String token,
Produto produto,
) async {
// PUT exige que TODOS os campos sejam enviados
final corpo = jsonEncode(produto.toJson());
final response = await client
.put(
ApiUris.produto(produto.id),
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'Authorization': 'Bearer $token',
},
body: corpo,
)
.timeout(const Duration(seconds: 10));
if (response.statusCode == 200) {
return Produto.fromJson(
jsonDecode(response.body) as Map<String, dynamic>,
);
} else {
throw ApiException(codigo: response.statusCode, mensagem: response.body);
}
}Future<Produto> atualizarProdutoCompleto(
http.Client client,
String token,
Produto produto,
) async {
final response = await client
.put(
ApiUris.produto(produto.id),
headers: _cabecalhosJson(token),
body: jsonEncode(produto.toJson()),
)
.timeout(const Duration(seconds: 10));
_verificarStatus(response);
return Produto.fromJson(jsonDecode(response.body) as Map<String, dynamic>);
}Seção 9 — DELETE: Removendo Recursos
O método DELETE remove o recurso identificado pela URI. Em APIs REST bem projetadas, uma exclusão bem-sucedida retorna o código 204 (No Content), indicando que o recurso foi removido e não há nada a retornar. Algumas APIs retornam 200 com o objeto excluído no corpo, mas a convenção mais comum é o 204.
Uma operação DELETE não tem corpo de requisição — apenas a URI e os cabeçalhos de autenticação são necessários. A idempotência é uma propriedade do DELETE: chamar DELETE /produtos/1 duas vezes deve ter o mesmo efeito que chamar uma vez — após a primeira chamada, o produto não existe mais, e a segunda chamada deve retornar 404 (pois o recurso já foi removido). Seu código deve tratar o 404 em resposta a um DELETE graciosamente, como uma condição normal.
Removendo um Produto do Cardápio
Future<void> removerProduto(
http.Client client,
String token,
int produtoId,
) async {
final uri = ApiUris.produto(produtoId);
final response = await client
.delete(
uri,
headers: {
'Accept': 'application/json',
'Authorization': 'Bearer $token',
},
)
.timeout(const Duration(seconds: 10));
// 204 No Content é a resposta esperada para DELETE bem-sucedido
if (response.statusCode == 204 || response.statusCode == 200) {
return; // Produto removido com sucesso
} else if (response.statusCode == 404) {
// Produto já não existe — pode tratar como sucesso silencioso
// ou lançar exceção dependendo da regra de negócio
throw NotFoundException('Produto $produtoId não encontrado para remoção.');
} else if (response.statusCode == 403) {
throw ForbiddenException('Sem permissão para remover produtos.');
} else {
throw ApiException(codigo: response.statusCode, mensagem: response.body);
}
}Future<void> removerProduto(
http.Client client,
String token,
int produtoId,
) async {
final response = await client
.delete(ApiUris.produto(produtoId), headers: _cabecalhos(token))
.timeout(const Duration(seconds: 10));
if (response.statusCode != 204 && response.statusCode != 200) {
_lancarExcecao(response);
}
}Seção 10 — Cabeçalhos HTTP: Comunicando Metadados
Os cabeçalhos HTTP são a camada de metadados da comunicação cliente-servidor. Eles não fazem parte dos dados em si — são informações sobre os dados, sobre o cliente, sobre o servidor e sobre como a requisição deve ser processada. Entender os cabeçalhos mais importantes e usá-los corretamente é sinal de maturidade técnica.
Os cabeçalhos são pares chave-valor em formato de texto, transmitidos antes do corpo da mensagem. São case-insensitive por definição do protocolo HTTP, mas a convenção é usar o formato Capitalized-Kebab-Case (primeira letra de cada palavra em maiúsculo, palavras separadas por hífen). No Dart, como você cria o mapa de cabeçalhos, a forma como você escreve as chaves é exatamente como elas serão enviadas ao servidor — certifique-se de escrever corretamente.
O cabeçalho Content-Type informa ao servidor o formato do corpo da requisição que você está enviando. Para JSON, o valor é application/json. Sem esse cabeçalho, o servidor pode não saber como interpretar o corpo — especialmente em APIs que aceitam múltiplos formatos como JSON e form-encoded. Esse cabeçalho é necessário apenas em requisições que têm corpo (POST, PUT, PATCH).
O cabeçalho Accept informa ao servidor o formato de resposta que o cliente aceita. Ao enviar Accept: application/json, você está dizendo que quer a resposta em JSON — o que é o padrão para APIs REST, mas pode ser relevante quando a API suporta múltiplos formatos de saída.
O cabeçalho Authorization é o mecanismo padrão para autenticação em APIs REST. O esquema mais comum para APIs móveis é o Bearer Token, onde o token JWT obtido no login é enviado em todas as requisições subsequentes: Authorization: Bearer <token>. O servidor valida o token, extrai as informações do usuário e decide se autoriza ou não a operação.
// Cria o mapa de cabeçalhos padrão para requisições que apenas leem dados (GET)
Map<String, String> cabecalhosLeitura(String token) {
return {
'Accept': 'application/json',
'Authorization': 'Bearer $token',
};
}
// Cria o mapa de cabeçalhos padrão para requisições que enviam dados (POST, PUT, PATCH)
Map<String, String> cabecalhosEscrita(String token) {
return {
'Content-Type': 'application/json',
'Accept': 'application/json',
'Authorization': 'Bearer $token',
};
}
// Exemplo de uso em uma requisição do app de delivery
Future<void> exemploUso(http.Client client, String token) async {
// GET não precisa de Content-Type — não tem corpo
final produtos = await client.get(
ApiUris.produtos(),
headers: cabecalhosLeitura(token),
);
// POST precisa de Content-Type — tem corpo JSON
final novoPedido = await client.post(
ApiUris.pedidos(),
headers: cabecalhosEscrita(token),
body: jsonEncode({'usuario_id': 'usr_1', 'itens': []}),
);
}// Centraliza a criação de cabeçalhos — ponto único de controle
abstract final class Cabecalhos {
static Map<String, String> leitura(String token) => {
'Accept': 'application/json',
'Authorization': 'Bearer $token',
};
static Map<String, String> escrita(String token) => {
'Content-Type': 'application/json',
'Accept': 'application/json',
'Authorization': 'Bearer $token',
};
}Centralizar a criação de cabeçalhos em uma classe utilitária como Cabecalhos traz dois benefícios concretos: elimina a duplicação de código (o cabeçalho Authorization escrito dez vezes em dez lugares diferentes é uma bomba-relógio) e cria um único ponto de alteração quando o esquema de autenticação mudar — por exemplo, ao migrar de Bearer Token para um esquema proprietário.
Seção 11 — Tratamento Robusto de Erros
Um aplicativo que funciona apenas em condições ideais não é um aplicativo — é uma demonstração. Redes móveis são inerentemente instáveis: o usuário pode estar em um túnel, o servidor pode estar sobrecarregado, o corpo da resposta pode estar corrompido. O seu código precisa estar preparado para todas essas situações e responder de forma elegante e informativa.
O tratamento de erros em requisições HTTP tem três dimensões distintas que precisam ser endereçadas separadamente. A primeira dimensão são os erros de rede: situações em que a comunicação TCP/IP falhou antes mesmo de o servidor receber a requisição, ou antes de a resposta chegar ao cliente. A segunda dimensão são os erros de protocolo: o servidor respondeu, mas com um código de status indicando falha (4xx ou 5xx). A terceira dimensão são os erros de parsing: o servidor respondeu com 200, mas o corpo da resposta não é um JSON válido, ou um campo esperado está ausente.
Erros de Rede
A exceção SocketException é lançada pelo Dart quando há um problema no nível de socket TCP — tipicamente porque o dispositivo não tem conexão com a internet, porque o hostname não pode ser resolvido pelo DNS ou porque o servidor está recusando conexões. Quando você captura uma SocketException, pode exibir ao usuário uma mensagem como “Sem conexão com a internet. Verifique sua rede e tente novamente.”
A TimeoutException é lançada quando o Future não completa dentro do tempo definido por .timeout(). Isso pode indicar que o servidor está lento, sobrecarregado ou que a conexão está com latência muito alta. A mensagem ao usuário pode ser “O servidor está demorando para responder. Tente novamente em alguns instantes.”
Erros de Protocolo
Quando o servidor retorna um código 4xx ou 5xx, o pacote http não lança uma exceção — ele retorna um objeto Response normalmente, com o statusCode indicando o erro. É sua responsabilidade verificar o statusCode e lançar uma exceção de domínio apropriada. Isso é uma escolha de design deliberada do pacote: como os diferentes códigos de erro requerem tratamentos muito diferentes, deixar a decisão para o desenvolvedor é mais flexível do que lançar exceções automaticamente.
Erros de Parsing
A FormatException é lançada quando jsonDecode() recebe uma string que não é um JSON válido. Isso pode acontecer quando o servidor retorna uma mensagem de erro em HTML (o que algumas implementações de servidor fazem em caso de falha grave), quando a resposta está truncada por um problema de rede ou quando a API tem um bug que produz JSON malformado.
Hierarquia de Exceções de Domínio
A melhor prática é criar uma hierarquia de exceções de domínio que representam os diferentes tipos de falha de forma semântica, independente dos detalhes do HTTP:
flowchart TD
A["Exception (Dart base)"] --> B["AppException\n(classe base do projeto)"]
B --> C["NetworkException\n(sem rede / timeout)"]
B --> D["ApiException\n(resposta HTTP recebida)"]
D --> E["UnauthorizedException\n(401)"]
D --> F["ForbiddenException\n(403)"]
D --> G["NotFoundException\n(404)"]
D --> H["UnprocessableException\n(422)"]
D --> I["ServerException\n(500)"]
B --> J["ParseException\n(JSON malformado)"]
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:http/http.dart' as http;
// Hierarquia de exceções de domínio
abstract class AppException implements Exception {
final String mensagem;
const AppException(this.mensagem);
@override
String toString() => mensagem;
}
class NetworkException extends AppException {
const NetworkException([String m = 'Sem conexão com a internet.']) : super(m);
}
class TimeoutApiException extends AppException {
const TimeoutApiException([String m = 'O servidor demorou para responder.']) : super(m);
}
class ApiException extends AppException {
final int codigo;
const ApiException({required this.codigo, required String mensagem}) : super(mensagem);
}
class UnauthorizedException extends ApiException {
const UnauthorizedException() : super(codigo: 401, mensagem: 'Sessão expirada. Faça login novamente.');
}
class ForbiddenException extends ApiException {
const ForbiddenException() : super(codigo: 403, mensagem: 'Sem permissão para realizar esta operação.');
}
class NotFoundException extends ApiException {
const NotFoundException([String m = 'Recurso não encontrado.']) : super(codigo: 404, mensagem: m);
}
class UnprocessableException extends ApiException {
const UnprocessableException([String m = 'Dados inválidos para esta operação.']) : super(codigo: 422, mensagem: m);
}
class ServerException extends ApiException {
const ServerException() : super(codigo: 500, mensagem: 'Erro interno do servidor. Tente mais tarde.');
}
class ParseException extends AppException {
const ParseException() : super('Resposta do servidor em formato inesperado.');
}
// Função auxiliar centralizada para executar requisições com tratamento de erros
Future<http.Response> executarRequisicao(Future<http.Response> Function() fn) async {
try {
return await fn();
} on SocketException {
// Sem conexão ou servidor inacessível
throw const NetworkException();
} on TimeoutException {
// Requisição demorou mais que o timeout configurado
throw const TimeoutApiException();
} on FormatException {
// Resposta não é JSON válido
throw const ParseException();
}
}
// Função auxiliar para verificar o status e lançar a exceção correta
void verificarStatus(http.Response response) {
if (response.statusCode >= 200 && response.statusCode < 300) {
return; // Sucesso — nenhuma exceção necessária
}
switch (response.statusCode) {
case 401:
throw const UnauthorizedException();
case 403:
throw const ForbiddenException();
case 404:
throw const NotFoundException();
case 422:
throw const UnprocessableException();
case 500:
throw const ServerException();
default:
throw ApiException(codigo: response.statusCode, mensagem: response.body);
}
}
// Exemplo de uso completo com todos os erros tratados
Future<List<Produto>> buscarProdutosComTratamentoDeErros(
http.Client client,
String token,
) async {
final response = await executarRequisicao(
() => client
.get(ApiUris.produtos(), headers: Cabecalhos.leitura(token))
.timeout(const Duration(seconds: 10)),
);
verificarStatus(response);
try {
final lista = jsonDecode(response.body) as List<dynamic>;
return lista
.cast<Map<String, dynamic>>()
.map(Produto.fromJson)
.toList();
} on FormatException {
throw const ParseException();
} on TypeError {
// Cast falhou — estrutura do JSON diferente do esperado
throw const ParseException();
}
}import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:http/http.dart' as http;
sealed class AppException implements Exception {
const AppException(this.mensagem);
final String mensagem;
@override String toString() => mensagem;
}
final class NetworkException extends AppException {
const NetworkException() : super('Sem conexão com a internet.');
}
final class TimeoutApiException extends AppException {
const TimeoutApiException() : super('O servidor demorou para responder.');
}
final class ParseException extends AppException {
const ParseException() : super('Resposta do servidor em formato inesperado.');
}
final class UnauthorizedException extends AppException {
const UnauthorizedException() : super('Sessão expirada. Faça login novamente.');
}
final class ForbiddenException extends AppException {
const ForbiddenException() : super('Sem permissão para esta operação.');
}
final class NotFoundException extends AppException {
const NotFoundException([String m = 'Recurso não encontrado.']) : super(m);
}
final class UnprocessableException extends AppException {
const UnprocessableException([String m = 'Dados inválidos.']) : super(m);
}
final class ServerException extends AppException {
const ServerException() : super('Erro interno do servidor. Tente mais tarde.');
}
final class ApiException extends AppException {
const ApiException(this.codigo, String mensagem) : super(mensagem);
final int codigo;
}
// Extensão em http.Response para tratamento semântico de status
extension ResponseX on http.Response {
void verificarStatus() {
if (statusCode >= 200 && statusCode < 300) return;
throw switch (statusCode) {
401 => const UnauthorizedException(),
403 => const ForbiddenException(),
404 => const NotFoundException(),
422 => const UnprocessableException(),
500 => const ServerException(),
_ => ApiException(statusCode, body),
};
}
}
// Wrapper genérico para capturar erros de infraestrutura
Future<T> executar<T>(Future<T> Function() fn) async {
try {
return await fn();
} on SocketException {
throw const NetworkException();
} on TimeoutException {
throw const TimeoutApiException();
} on FormatException {
throw const ParseException();
} on TypeError {
throw const ParseException();
}
}O uso de sealed class na versão otimizada é uma funcionalidade poderosa do Dart 3: com ela, o compilador garante que um switch sobre subclasses de AppException seja exaustivo — se você adicionar uma nova subclasse e esquecer de tratá-la em algum lugar, o compilador vai reclamar. Isso torna a hierarquia de exceções muito mais segura para manter.
Seção 12 — Integrando com Provider: Estados de UI para Requisições
Toda requisição assíncrona tem três estados que a interface do usuário precisa comunicar de forma clara: o estado de carregamento (enquanto a requisição está em andamento), o estado de sucesso (quando os dados chegaram) e o estado de erro (quando algo deu errado). Modelar esses estados explicitamente no seu ChangeNotifier e refleti-los na UI é uma das práticas que separa aplicativos amadores de aplicativos profissionais.
No Módulo 07, você aprendeu a usar Provider e ChangeNotifier para gerenciar estado. Agora você vai estender esse conhecimento para o caso específico de requisições HTTP. O padrão é sempre o mesmo: antes de iniciar a requisição, o notifier notifica os widgets que o estado mudou para “carregando”; quando a resposta chega, atualiza o estado para “sucesso” ou “erro” e notifica novamente.
Um detalhe importante sobre o ciclo de vida: você nunca deve disparar uma requisição HTTP diretamente dentro do método build() de um widget — isso causaria infinitas requisições a cada rebuild. O lugar correto para disparar a requisição inicial é o initState() do widget, mas com um cuidado: você não pode chamar métodos de um Provider que modificam estado durante o initState(), pois o widget ainda está sendo construído. A solução é usar addPostFrameCallback, que agenda a execução para depois do primeiro frame ser exibido.
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
// Enum para representar os três estados possíveis
enum EstadoRequisicao { inicial, carregando, sucesso, erro }
// Notifier que gerencia o cardápio de produtos
class CardapioNotifier extends ChangeNotifier {
final IProdutoRepository _repository;
CardapioNotifier(this._repository);
EstadoRequisicao _estado = EstadoRequisicao.inicial;
List<Produto> _produtos = [];
String _mensagemErro = '';
// Getters para que os widgets acessem o estado (sem modificação direta)
EstadoRequisicao get estado => _estado;
List<Produto> get produtos => _produtos;
String get mensagemErro => _mensagemErro;
// Carrega os produtos da API
Future<void> carregarCardapio() async {
// Evita iniciar uma segunda requisição se já há uma em andamento
if (_estado == EstadoRequisicao.carregando) return;
_estado = EstadoRequisicao.carregando;
_mensagemErro = '';
notifyListeners(); // Informa os widgets: mostre o indicador de progresso
try {
_produtos = await _repository.listarProdutos();
_estado = EstadoRequisicao.sucesso;
} on NetworkException catch (e) {
_estado = EstadoRequisicao.erro;
_mensagemErro = e.mensagem;
} on AppException catch (e) {
_estado = EstadoRequisicao.erro;
_mensagemErro = e.mensagem;
} catch (e) {
_estado = EstadoRequisicao.erro;
_mensagemErro = 'Ocorreu um erro inesperado. Tente novamente.';
}
notifyListeners(); // Informa os widgets: atualize a interface
}
// Filtra produtos por categoria (operação local, sem nova requisição)
List<Produto> produtosPorCategoria(String categoria) {
return _produtos.where((p) => p.categoria == categoria).toList();
}
}
// Widget que consome o CardapioNotifier
class TelaCardapio extends StatefulWidget {
const TelaCardapio({super.key});
@override
State<TelaCardapio> createState() => _TelaCardapioState();
}
class _TelaCardapioState extends State<TelaCardapio> {
@override
void initState() {
super.initState();
// Usa addPostFrameCallback para disparar a requisição após o primeiro build
WidgetsBinding.instance.addPostFrameCallback((_) {
// read() ao invés de watch() porque estamos fora do build()
context.read<CardapioNotifier>().carregarCardapio();
});
}
@override
Widget build(BuildContext context) {
// watch() ouve as mudanças e reconstrói o widget quando o estado muda
final notifier = context.watch<CardapioNotifier>();
return Scaffold(
appBar: AppBar(title: const Text('Cardápio')),
body: switch (notifier.estado) {
EstadoRequisicao.inicial || EstadoRequisicao.carregando =>
const Center(child: CircularProgressIndicator()),
EstadoRequisicao.erro => Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
notifier.mensagemErro,
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.red),
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () => context.read<CardapioNotifier>().carregarCardapio(),
child: const Text('Tentar Novamente'),
),
],
),
),
EstadoRequisicao.sucesso => ListView.builder(
itemCount: notifier.produtos.length,
itemBuilder: (context, index) {
final produto = notifier.produtos[index];
return ListTile(
title: Text(produto.nome),
subtitle: Text(produto.categoria),
trailing: Text('R\$ ${produto.preco.toStringAsFixed(2)}'),
enabled: produto.disponivel,
);
},
),
},
);
}
}import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
sealed class CardapioEstado {
const CardapioEstado();
}
final class CardapioInicial extends CardapioEstado {
const CardapioInicial();
}
final class CardapioCarregando extends CardapioEstado {
const CardapioCarregando();
}
final class CardapioSucesso extends CardapioEstado {
const CardapioSucesso(this.produtos);
final List<Produto> produtos;
}
final class CardapioErro extends CardapioEstado {
const CardapioErro(this.mensagem);
final String mensagem;
}
final class CardapioNotifier extends ChangeNotifier {
CardapioNotifier(this._repository);
final IProdutoRepository _repository;
CardapioEstado _estado = const CardapioInicial();
CardapioEstado get estado => _estado;
bool get _carregando => _estado is CardapioCarregando;
Future<void> carregarCardapio() async {
if (_carregando) return;
_atualizar(const CardapioCarregando());
try {
_atualizar(CardapioSucesso(await _repository.listarProdutos()));
} on AppException catch (e) {
_atualizar(CardapioErro(e.mensagem));
} catch (_) {
_atualizar(const CardapioErro('Erro inesperado. Tente novamente.'));
}
}
void _atualizar(CardapioEstado estado) {
_estado = estado;
notifyListeners();
}
}
class TelaCardapio extends StatefulWidget {
const TelaCardapio({super.key});
@override
State<TelaCardapio> createState() => _TelaCardapioState();
}
class _TelaCardapioState extends State<TelaCardapio> {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback(
(_) => context.read<CardapioNotifier>().carregarCardapio(),
);
}
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(title: const Text('Cardápio')),
body: switch (context.watch<CardapioNotifier>().estado) {
CardapioInicial() || CardapioCarregando() =>
const Center(child: CircularProgressIndicator()),
CardapioErro(:final mensagem) => _ErroView(
mensagem: mensagem,
onRetry: context.read<CardapioNotifier>().carregarCardapio,
),
CardapioSucesso(:final produtos) => _ListaProdutos(produtos: produtos),
},
);
}
class _ErroView extends StatelessWidget {
const _ErroView({required this.mensagem, required this.onRetry});
final String mensagem;
final VoidCallback onRetry;
@override
Widget build(BuildContext context) => Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(mensagem, textAlign: TextAlign.center),
const SizedBox(height: 16),
ElevatedButton(onPressed: onRetry, child: const Text('Tentar Novamente')),
],
),
);
}
class _ListaProdutos extends StatelessWidget {
const _ListaProdutos({required this.produtos});
final List<Produto> produtos;
@override
Widget build(BuildContext context) => ListView.builder(
itemCount: produtos.length,
itemBuilder: (_, i) => ListTile(
title: Text(produtos[i].nome),
subtitle: Text(produtos[i].categoria),
trailing: Text('R\$ ${produtos[i].preco.toStringAsFixed(2)}'),
enabled: produtos[i].disponivel,
),
);
}Observe a diferença de abordagem na modelagem do estado. A versão didática usa um enum EstadoRequisicao combinado com campos separados para dados e erro — uma abordagem simples e funcional. A versão otimizada usa uma sealed class com subclasses para cada estado, o que permite usar pattern matching para acessar os dados associados a cada estado diretamente, sem precisar de campos opcionais que podem estar ou não preenchidos dependendo do estado atual.
Seção 13 — O Padrão Repository para Dados Remotos
No Módulo 08, você aprendeu o padrão Repository aplicado à persistência local com SQLite. A beleza desse padrão é que ele define um contrato (a interface) completamente separado de qualquer implementação específica. O mesmo contrato que hoje aponta para o banco de dados local amanhã pode apontar para a API REST — sem que o restante da aplicação saiba da diferença.
O padrão Repository é um dos pilares da arquitetura hexagonal que você está usando no Projeto Integrador. Na camada de domínio, você define uma interface abstrata que descreve as operações que a aplicação precisa sobre os dados — independente de como esses dados são armazenados ou de onde eles vêm. Na camada de infraestrutura, você cria implementações concretas dessa interface: uma para SQLite, outra para HTTP, e potencialmente uma terceira que combina as duas.
Isso significa que seu CardapioNotifier — que reside na camada de apresentação — fala exclusivamente com a interface IProdutoRepository. Ele nunca sabe se está falando com o banco de dados local ou com a API REST. Essa ignorância é intencional e é o que torna o código testável, flexível e evolutivo.
O diagrama a seguir mostra como as camadas se relacionam:
flowchart LR
W["TelaCardapio\n(Widget)"] --> N["CardapioNotifier\n(Provider)"]
N --> I["IProdutoRepository\n(interface no domínio)"]
I --> S["SqfliteProdutoRepository\n(infraestrutura - local)"]
I --> H["HttpProdutoRepository\n(infraestrutura - remoto)"]
S --> DB[("SQLite\n(dispositivo)")]
H --> API[("API REST\nhttps://api.delivery.example.com")]
style I fill:#f9f,stroke:#333
style N fill:#bbf,stroke:#333
import 'dart:convert';
import 'dart:async';
import 'dart:io';
import 'package:http/http.dart' as http;
// Interface no domínio — sem nenhuma dependência de infraestrutura
abstract interface class IProdutoRepository {
Future<List<Produto>> listarProdutos();
Future<List<Produto>> listarProdutosPorCategoria(String categoria);
Future<Produto> buscarProduto(int id);
Future<Produto> criarProduto(Produto produto);
Future<Produto> atualizarProduto(Produto produto);
Future<void> atualizarDisponibilidade(int id, bool disponivel);
Future<void> removerProduto(int id);
}
// Implementação concreta que usa HTTP
class HttpProdutoRepository implements IProdutoRepository {
final http.Client _client;
final String _token; // Em produção, isso viria de um serviço de autenticação
HttpProdutoRepository({
required http.Client client,
required String token,
}) : _client = client,
_token = token;
@override
Future<List<Produto>> listarProdutos() async {
final response = await executar(
() => _client
.get(ApiUris.produtos(), headers: Cabecalhos.leitura(_token))
.timeout(const Duration(seconds: 10)),
);
response.verificarStatus();
return (jsonDecode(response.body) as List)
.cast<Map<String, dynamic>>()
.map(Produto.fromJson)
.toList();
}
@override
Future<List<Produto>> listarProdutosPorCategoria(String categoria) async {
final response = await executar(
() => _client
.get(
ApiUris.produtos({'categoria': categoria}),
headers: Cabecalhos.leitura(_token),
)
.timeout(const Duration(seconds: 10)),
);
response.verificarStatus();
return (jsonDecode(response.body) as List)
.cast<Map<String, dynamic>>()
.map(Produto.fromJson)
.toList();
}
@override
Future<Produto> buscarProduto(int id) async {
final response = await executar(
() => _client
.get(ApiUris.produto(id), headers: Cabecalhos.leitura(_token))
.timeout(const Duration(seconds: 10)),
);
response.verificarStatus();
return Produto.fromJson(
jsonDecode(response.body) as Map<String, dynamic>,
);
}
@override
Future<Produto> criarProduto(Produto produto) async {
final response = await executar(
() => _client
.post(
ApiUris.produtos(),
headers: Cabecalhos.escrita(_token),
body: jsonEncode(produto.toJson()),
)
.timeout(const Duration(seconds: 15)),
);
response.verificarStatus();
return Produto.fromJson(
jsonDecode(response.body) as Map<String, dynamic>,
);
}
@override
Future<Produto> atualizarProduto(Produto produto) async {
final response = await executar(
() => _client
.put(
ApiUris.produto(produto.id),
headers: Cabecalhos.escrita(_token),
body: jsonEncode(produto.toJson()),
)
.timeout(const Duration(seconds: 15)),
);
response.verificarStatus();
return Produto.fromJson(
jsonDecode(response.body) as Map<String, dynamic>,
);
}
@override
Future<void> atualizarDisponibilidade(int id, bool disponivel) async {
final response = await executar(
() => _client
.patch(
ApiUris.produto(id),
headers: Cabecalhos.escrita(_token),
body: jsonEncode({'disponivel': disponivel}),
)
.timeout(const Duration(seconds: 10)),
);
if (response.statusCode != 200 && response.statusCode != 204) {
response.verificarStatus();
}
}
@override
Future<void> removerProduto(int id) async {
final response = await executar(
() => _client
.delete(ApiUris.produto(id), headers: Cabecalhos.leitura(_token))
.timeout(const Duration(seconds: 10)),
);
if (response.statusCode != 200 && response.statusCode != 204) {
response.verificarStatus();
}
}
}import 'dart:convert';
import 'package:http/http.dart' as http;
final class HttpProdutoRepository implements IProdutoRepository {
const HttpProdutoRepository(this._client, this._tokenSource);
final http.Client _client;
final TokenSource _tokenSource; // abstração que fornece o token atual
String get _token => _tokenSource.token;
Future<http.Response> _get(Uri uri) => executar(
() => _client
.get(uri, headers: Cabecalhos.leitura(_token))
.timeout(const Duration(seconds: 10)),
);
Future<http.Response> _send(
Future<http.Response> Function() fn,
) => executar(() => fn().timeout(const Duration(seconds: 15)));
@override
Future<List<Produto>> listarProdutos() async =>
_parseLista(await _get(ApiUris.produtos())..verificarStatus());
@override
Future<List<Produto>> listarProdutosPorCategoria(String cat) async =>
_parseLista(await _get(ApiUris.produtos({'categoria': cat}))..verificarStatus());
@override
Future<Produto> buscarProduto(int id) async =>
Produto.fromJson(_parseMap(await _get(ApiUris.produto(id))..verificarStatus()));
@override
Future<Produto> criarProduto(Produto p) async => Produto.fromJson(
_parseMap(
await _send(() => _client.post(
ApiUris.produtos(),
headers: Cabecalhos.escrita(_token),
body: jsonEncode(p.toJson()),
))
..verificarStatus(),
),
);
@override
Future<Produto> atualizarProduto(Produto p) async => Produto.fromJson(
_parseMap(
await _send(() => _client.put(
ApiUris.produto(p.id),
headers: Cabecalhos.escrita(_token),
body: jsonEncode(p.toJson()),
))
..verificarStatus(),
),
);
@override
Future<void> atualizarDisponibilidade(int id, bool v) async =>
(await _send(() => _client.patch(
ApiUris.produto(id),
headers: Cabecalhos.escrita(_token),
body: jsonEncode({'disponivel': v}),
)))
.verificarStatus();
@override
Future<void> removerProduto(int id) async =>
(await _send(() => _client.delete(
ApiUris.produto(id),
headers: Cabecalhos.leitura(_token),
)))
.verificarStatus();
static List<Produto> _parseLista(http.Response r) =>
(jsonDecode(r.body) as List).cast<Map<String, dynamic>>().map(Produto.fromJson).toList();
static Map<String, dynamic> _parseMap(http.Response r) =>
jsonDecode(r.body) as Map<String, dynamic>;
}Observe a introdução de TokenSource na versão otimizada — uma abstração que fornece o token de autenticação atual. Isso é importante porque o token pode mudar ao longo do ciclo de vida do repositório (por exemplo, após um refresh automático), e passar o token como parâmetro de construtor fixaria o valor no momento da criação. Com TokenSource, o repositório sempre busca o token mais atual antes de cada requisição.
Seção 14 — Cache Local + Dados Remotos: a Estratégia Completa
Conectar o aplicativo à API REST resolve um problema — os dados agora são reais e atualizados — mas cria outro: toda vez que o usuário abre o cardápio, o aplicativo precisa fazer uma requisição de rede. Em uma conexão móvel ruim, isso pode significar vários segundos de espera. A solução é combinar o repositório HTTP do módulo atual com o repositório SQLite do Módulo 08 em uma estratégia de cache inteligente.
O padrão que você vai implementar é conhecido como cache-aside ou lazy loading: o repositório híbrido primeiro verifica se existe dados frescos no cache local. Se existirem e forem recentes o suficiente, retorna-os imediatamente sem fazer nenhuma requisição de rede. Se não existirem ou estiverem desatualizados, busca os dados da API, salva-os no SQLite para uso futuro e retorna os dados frescos.
O critério de “frescor” do cache é controlado por um timestamp salvo no SharedPreferences. Quando o cache foi salvo pela última vez, você registra o momento. Na próxima consulta, você compara esse momento com o horário atual. Se a diferença for menor que o tempo de vida configurado (por exemplo, 30 minutos para o cardápio de produtos), o cache é considerado válido.
flowchart TD
A["listarProdutos() chamado"] --> B{"Cache existe\nno SQLite?"}
B -- Não --> F
B -- Sim --> C{"Cache está\nfresco?\n(< 30 minutos)"}
C -- Sim --> D["Retornar produtos\ndo SQLite"]
C -- Não --> F["Buscar da API REST\n(HTTP GET /produtos)"]
F --> G{"Requisição\nbem-sucedida?"}
G -- Não --> H{"Cache existe\n(mesmo expirado)?"}
H -- Sim --> I["Retornar cache\nexpirado com aviso"]
H -- Não --> J["Lançar NetworkException"]
G -- Sim --> K["Limpar produtos\nno SQLite"]
K --> L["Inserir novos produtos\nno SQLite"]
L --> M["Atualizar timestamp\nno SharedPreferences"]
M --> D
import 'package:shared_preferences/shared_preferences.dart';
// Constante: tempo de vida do cache em minutos
const int _tempoVidaCacheMinutos = 30;
const String _chaveUltimaAtualizacao = 'cardapio_ultima_atualizacao';
class CacheAdaptadorProdutoRepository implements IProdutoRepository {
final IProdutoRepository _repositorioRemoto; // HttpProdutoRepository
final IProdutoRepository _repositorioLocal; // SqfliteProdutoRepository
final SharedPreferences _prefs;
CacheAdaptadorProdutoRepository({
required IProdutoRepository repositorioRemoto,
required IProdutoRepository repositorioLocal,
required SharedPreferences prefs,
}) : _repositorioRemoto = repositorioRemoto,
_repositorioLocal = repositorioLocal,
_prefs = prefs;
// Verifica se o cache ainda é válido
bool _cacheEstaFresco() {
final ultimaAtualizacaoMs = _prefs.getInt(_chaveUltimaAtualizacao);
if (ultimaAtualizacaoMs == null) return false; // Nunca foi atualizado
final ultimaAtualizacao = DateTime.fromMillisecondsSinceEpoch(ultimaAtualizacaoMs);
final agora = DateTime.now();
final diferenca = agora.difference(ultimaAtualizacao);
return diferenca.inMinutes < _tempoVidaCacheMinutos;
}
// Atualiza o timestamp do cache
Future<void> _registrarAtualizacaoCache() async {
await _prefs.setInt(
_chaveUltimaAtualizacao,
DateTime.now().millisecondsSinceEpoch,
);
}
@override
Future<List<Produto>> listarProdutos() async {
// Passo 1: verificar se o cache está fresco
if (_cacheEstaFresco()) {
// Retorna do banco de dados local — extremamente rápido
return _repositorioLocal.listarProdutos();
}
// Passo 2: cache expirado ou inexistente — busca da API
try {
final produtos = await _repositorioRemoto.listarProdutos();
// Passo 3: salva os novos dados no cache local
// (SqfliteProdutoRepository deve ter um método para substituir todos)
for (final produto in produtos) {
await _repositorioLocal.criarProduto(produto);
}
// Passo 4: registra quando o cache foi atualizado
await _registrarAtualizacaoCache();
return produtos;
} on NetworkException {
// Passo 5: sem rede — tenta retornar o cache expirado como fallback
final cacheLegado = await _repositorioLocal.listarProdutos();
if (cacheLegado.isNotEmpty) {
// Retorna o cache expirado — melhor do que nada
return cacheLegado;
}
// Sem cache local e sem rede — propaga o erro
rethrow;
}
}
// Para os outros métodos, delega direto para o repositório remoto
// (alterações sempre vão para a API e depois atualizam o cache)
@override
Future<Produto> buscarProduto(int id) => _repositorioRemoto.buscarProduto(id);
@override
Future<List<Produto>> listarProdutosPorCategoria(String categoria) async {
// Filtra localmente se o cache estiver fresco
if (_cacheEstaFresco()) {
return _repositorioLocal.listarProdutosPorCategoria(categoria);
}
// Caso contrário, recarrega o cache completo primeiro
await listarProdutos();
return _repositorioLocal.listarProdutosPorCategoria(categoria);
}
@override
Future<Produto> criarProduto(Produto produto) async {
final criado = await _repositorioRemoto.criarProduto(produto);
// Invalida o cache para forçar atualização na próxima leitura
await _prefs.remove(_chaveUltimaAtualizacao);
return criado;
}
@override
Future<Produto> atualizarProduto(Produto produto) async {
final atualizado = await _repositorioRemoto.atualizarProduto(produto);
await _prefs.remove(_chaveUltimaAtualizacao);
return atualizado;
}
@override
Future<void> atualizarDisponibilidade(int id, bool disponivel) async {
await _repositorioRemoto.atualizarDisponibilidade(id, disponivel);
await _prefs.remove(_chaveUltimaAtualizacao);
}
@override
Future<void> removerProduto(int id) async {
await _repositorioRemoto.removerProduto(id);
await _prefs.remove(_chaveUltimaAtualizacao);
}
}import 'package:shared_preferences/shared_preferences.dart';
final class CacheAdaptadorProdutoRepository implements IProdutoRepository {
const CacheAdaptadorProdutoRepository({
required IProdutoRepository remoto,
required IProdutoRepository local,
required SharedPreferences prefs,
Duration ttl = const Duration(minutes: 30),
}) : _remoto = remoto,
_local = local,
_prefs = prefs,
_ttl = ttl;
static const _chaveTs = 'cardapio_ts';
final IProdutoRepository _remoto;
final IProdutoRepository _local;
final SharedPreferences _prefs;
final Duration _ttl;
bool get _fresco {
final ms = _prefs.getInt(_chaveTs);
if (ms == null) return false;
return DateTime.now().difference(DateTime.fromMillisecondsSinceEpoch(ms)) < _ttl;
}
Future<void> _invalidar() => _prefs.remove(_chaveTs);
Future<void> _marcarFresco() =>
_prefs.setInt(_chaveTs, DateTime.now().millisecondsSinceEpoch);
@override
Future<List<Produto>> listarProdutos() async {
if (_fresco) return _local.listarProdutos();
try {
final produtos = await _remoto.listarProdutos();
for (final p in produtos) await _local.criarProduto(p);
await _marcarFresco();
return produtos;
} on NetworkException {
final fallback = await _local.listarProdutos();
return fallback.isNotEmpty ? fallback : (rethrow);
}
}
@override
Future<List<Produto>> listarProdutosPorCategoria(String cat) async {
if (!_fresco) await listarProdutos();
return _local.listarProdutosPorCategoria(cat);
}
@override
Future<Produto> buscarProduto(int id) => _remoto.buscarProduto(id);
@override
Future<Produto> criarProduto(Produto p) async {
final r = await _remoto.criarProduto(p);
await _invalidar();
return r;
}
@override
Future<Produto> atualizarProduto(Produto p) async {
final r = await _remoto.atualizarProduto(p);
await _invalidar();
return r;
}
@override
Future<void> atualizarDisponibilidade(int id, bool v) async {
await _remoto.atualizarDisponibilidade(id, v);
await _invalidar();
}
@override
Future<void> removerProduto(int id) async {
await _remoto.removerProduto(id);
await _invalidar();
}
}O padrão implementado aqui — um repositório que coordena dois outros repositórios — é conhecido como Decorator ou Adapter na literatura de design patterns. O CacheAdaptadorProdutoRepository decora a interface IProdutoRepository adicionando comportamento de cache sem modificar as implementações existentes. Isso exemplifica perfeitamente o Princípio Aberto-Fechado (OCP) do SOLID: você estende o comportamento sem modificar o código existente.
Seção 15 — Retry Automático para Falhas Transitórias
Redes móveis são inerentemente instáveis. Um usuário que está caminhando pode perder o sinal por um segundo e recuperá-lo imediatamente. Um servidor que está sendo reiniciado pode recusar conexões por alguns instantes antes de voltar ao ar. Essas são falhas transitórias — erros que desaparecem por conta própria se você simplesmente tentar novamente após um breve intervalo.
Implementar retry automático para falhas transitórias é uma das melhorias de resiliência mais impactantes que você pode adicionar ao seu aplicativo. Sem retry, uma falha transitória vira uma experiência negativa para o usuário: ele vê uma mensagem de erro, precisa tocar em “Tentar Novamente” manualmente e esperar novamente. Com retry automático, o aplicativo simplesmente tenta de novo nos bastidores e, na maioria dos casos, a segunda tentativa funciona.
A estratégia de retry não pode ser ingênua. Fazer retry imediatamente e infinitamente — ou com frequência muito alta — pode agravar o problema: se o servidor está sobrecarregado e você inunda-o com novas requisições a cada meio segundo, você piora a situação para todos os clientes. A abordagem correta é o exponential backoff: você espera 1 segundo antes da primeira retentativa, 2 segundos antes da segunda, 4 segundos antes da terceira, e assim por diante. Isso dá ao servidor tempo para se recuperar antes de cada nova tentativa.
Nem toda falha merece retry. Erros 4xx (400, 401, 403, 404, 422) são erros do cliente — a requisição está incorreta, e tentar novamente com os mesmos dados produzirá exatamente o mesmo erro. O retry faz sentido apenas para:
SocketException— falha de rede possivelmente transitóriaTimeoutException— o servidor pode estar temporariamente lento- Código de status 503 (Service Unavailable) — o servidor está temporariamente indisponível
- Código de status 429 (Too Many Requests) — com backoff maior, respeitando o cabeçalho
Retry-Afterse disponível - Código de status 500 em alguns contextos — falha temporária do servidor
import 'dart:async';
// Função genérica de retry com exponential backoff
// T é o tipo de retorno da operação
Future<T> comRetry<T>(
Future<T> Function() operacao, {
int maxTentativas = 3,
Duration intervaloInicial = const Duration(seconds: 1),
}) async {
int tentativa = 0;
while (true) {
tentativa++;
try {
// Tenta executar a operação
return await operacao();
} on NetworkException catch (e) {
// Falha de rede — vale a pena tentar de novo
if (tentativa >= maxTentativas) {
rethrow; // Esgotou as tentativas — propaga o erro
}
// Backoff exponencial: 1s, 2s, 4s, 8s...
final espera = intervaloInicial * (1 << (tentativa - 1)); // 2^(tentativa-1)
await Future.delayed(espera);
// Continua o loop para tentar novamente
} on TimeoutApiException catch (e) {
// Timeout — servidor lento, vale tentar novamente
if (tentativa >= maxTentativas) rethrow;
final espera = intervaloInicial * (1 << (tentativa - 1));
await Future.delayed(espera);
} on ApiException catch (e) {
// Erros 5xx transitórios merecem retry; 4xx não
if (e is ServerException && tentativa < maxTentativas) {
final espera = intervaloInicial * (1 << (tentativa - 1));
await Future.delayed(espera);
continue;
}
rethrow; // Outros erros de API propagam imediatamente
}
// Nota: AppException e outros erros não relativos a rede propagam imediatamente
}
}
// Uso no repositório
class HttpProdutoRepositoryComRetry implements IProdutoRepository {
final HttpProdutoRepository _repositorio;
HttpProdutoRepositoryComRetry(this._repositorio);
@override
Future<List<Produto>> listarProdutos() => comRetry(
() => _repositorio.listarProdutos(),
maxTentativas: 3,
intervaloInicial: const Duration(seconds: 1),
);
@override
Future<Produto> buscarProduto(int id) => comRetry(
() => _repositorio.buscarProduto(id),
);
@override
Future<Produto> criarProduto(Produto produto) =>
_repositorio.criarProduto(produto); // POST não deve ter retry automático
@override
Future<List<Produto>> listarProdutosPorCategoria(String categoria) =>
comRetry(() => _repositorio.listarProdutosPorCategoria(categoria));
@override
Future<Produto> atualizarProduto(Produto produto) =>
_repositorio.atualizarProduto(produto); // Idempotente, poderia ter retry
@override
Future<void> atualizarDisponibilidade(int id, bool disponivel) =>
_repositorio.atualizarDisponibilidade(id, disponivel);
@override
Future<void> removerProduto(int id) => _repositorio.removerProduto(id);
}import 'dart:async';
import 'dart:math';
// Determina se uma exceção é candidata a retry
bool _ehTransitoria(Object e) => switch (e) {
NetworkException() || TimeoutApiException() => true,
ServerException() => true,
_ => false,
};
Future<T> comRetry<T>(
Future<T> Function() fn, {
int maxTentativas = 3,
Duration base = const Duration(seconds: 1),
}) async {
for (var i = 0; i < maxTentativas; i++) {
try {
return await fn();
} catch (e) {
if (!_ehTransitoria(e) || i == maxTentativas - 1) rethrow;
// Exponential backoff com jitter para evitar thundering herd
final delay = base * pow(2, i).toInt() +
Duration(milliseconds: Random().nextInt(500));
await Future.delayed(delay);
}
}
// Nunca alcançado — o loop sempre lança ou retorna
throw StateError('comRetry: estado inalcançável');
}Observe o “jitter” — uma variação aleatória adicionada ao intervalo de espera na versão otimizada. Quando muitos clientes simultaneamente enfrentam a mesma falha transitória (por exemplo, um servidor que reinicia), todos eles tentariam de novo exatamente ao mesmo tempo, o que pode gerar um novo pico de carga. O jitter distribui as tentativas em uma janela de tempo, reduzindo o impacto coletivo.
Seção 16 — Boas Práticas e Considerações de Segurança
Consumir APIs REST de forma segura e profissional vai além de fazer as requisições funcionarem. Existem práticas que protegem os dados dos seus usuários, facilitam a manutenção do código e evitam problemas sérios em produção.
A primeira consideração é sobre onde você guarda as URLs da API. Nunca escreva a URL da API como uma string literal espalhada pelo código-fonte. O motivo é duplo: se a URL mudar, você terá que localizar e alterar cada ocorrência — o que é propenso a erro. Além disso, se o seu repositório for público (como no GitHub), a URL fica exposta. A abordagem correta é centralizar a configuração em um único arquivo ou classe, de preferência carregando o valor de variáveis de ambiente em tempo de compilação usando --dart-define ou --dart-define-from-file.
A segunda consideração é o uso exclusivo de HTTPS em produção. O HTTP simples transmite todos os dados — incluindo tokens de autenticação e dados do usuário — em texto claro, visível para qualquer observador na rede local. HTTPS encripta a comunicação com TLS, protegendo contra interceptação. Para desenvolvimento local, usar http://localhost é aceitável, mas jamais use HTTP para um servidor externo em produção.
A terceira consideração é sobre o que você registra em logs. Nunca faça print() ou registre em log o corpo completo de requisições ou respostas que contenham dados sensíveis: tokens de autenticação, senhas, dados pessoais do usuário, informações de pagamento. Em desenvolvimento, um log bem estruturado é uma ferramenta valiosa, mas em produção, os logs devem ser tratados como dados sensíveis.
A quarta consideração é a validação defensiva das respostas da API. Nunca assuma que um campo vai existir na resposta da API. APIs mudam, e um campo que hoje é sempre presente pode tornar-se opcional em uma versão futura. Use o operador de acesso seguro (?.) e valores padrão (??) ao parsear JSON, ou adicione verificações explícitas antes de fazer casts.
A quinta consideração é o gerenciamento correto do ciclo de vida do http.Client. Um http.Client instanciado mantém um pool de conexões TCP abertas — um recurso finito do sistema operacional. Quando você não precisar mais do cliente, chame client.close(). No contexto de um repositório registrado como singleton no GetIt, o lugar correto para fechar o cliente é no shutdown do aplicativo.
Nunca commite tokens, chaves de API ou URLs de ambientes de produção diretamente no código-fonte. Use o arquivo .env (gitignored) combinado com --dart-define-from-file no Flutter, ou armazene segredos no GitHub Actions Secrets se for usar CI/CD. Um token exposto em um repositório público é comprometido imediatamente.
Atenção ao cast de tipos em JSON. O erro type 'int' is not a subtype of type 'double' é um dos mais comuns ao parsear respostas de API em Dart. Sempre use (json['campo'] as num).toDouble() para campos numéricos que podem ser decimais — nunca json['campo'] as double diretamente.
A sexta consideração é o tratamento correto de encoding do corpo da resposta. O pacote http por padrão decodifica o corpo da resposta usando latin-1 quando o cabeçalho Content-Type não especifica um charset. Para APIs que retornam UTF-8 (o que é o correto para JSON com caracteres acentuados), isso pode causar corrupção de caracteres. A solução é usar utf8.decode(response.bodyBytes) em vez de response.body ao decodificar o JSON:
Essa é uma diferença sutil que se manifesta apenas quando a resposta contém caracteres fora do ASCII — exatamente os nomes de produtos com acentos que o cardápio do seu aplicativo de delivery certamente terá.
Seção 17 — Síntese do Módulo
Você percorreu um caminho longo e denso neste módulo. Partiu do entendimento profundo do protocolo HTTP e do estilo arquitetural REST, compreendeu a semântica dos verbos e dos códigos de status, aprendeu a construir URIs corretamente, a serializar e desserializar JSON com segurança de tipos, a usar todos os verbos HTTP com o pacote http, a tratar erros de forma hierárquica e semântica, a integrar requisições assíncronas com o Provider, a estender o padrão Repository para dados remotos, a combinar repositório remoto com cache local e a implementar retry com backoff exponencial.
O que você construiu neste módulo é a espinha dorsal da integração do aplicativo de delivery com o mundo externo. O HttpProdutoRepository e o CacheAdaptadorProdutoRepository que você implementou são componentes de produção — código que você poderia colocar em um aplicativo real agora mesmo.
Mas o backend que a sua API consome neste módulo ainda é fictício: o domínio api.delivery.example.com não existe. No Módulo 10, você vai construir o backend real usando os serviços da AWS: o API Gateway vai expor os endpoints REST, as Lambda Functions vão implementar a lógica de negócio e o RDS PostgreSQL vai persistir os dados. Quando isso estiver pronto, o único ajuste necessário no seu código Flutter será mudar o host em ApiUris de api.delivery.example.com para a URL real do seu API Gateway.
Esse é o poder da arquitetura que você está construindo: o isolamento correto entre camadas garante que mudanças de infraestrutura — trocar um servidor fictício por um real — não se propagam para o restante da aplicação. O CardapioNotifier não saberá que houve uma mudança. Os widgets também não.
Para o Projeto Integrador: aplique tudo que você aprendeu aqui ao seu próprio domínio. Defina os endpoints da sua API, crie as classes de modelo com fromJson/toJson, implemente o repositório HTTP e integre-o ao notifier que você construiu no Módulo 07. Se a sua API real ainda não está pronta, use dados mockados — crie uma implementação de IProdutoRepository que retorna dados fixos — e substitua pela implementação HTTP quando o backend estiver disponível.
A tabela a seguir resume o que você aprendeu e onde cada conceito se encaixa na arquitetura do Projeto Integrador:
| Conceito | Localização no Projeto | Arquivo típico |
|---|---|---|
Classes de modelo com fromJson/toJson |
Domínio | features/cardapio/domain/entities/produto.dart |
IProdutoRepository (interface) |
Domínio | features/cardapio/domain/repositories/i_produto_repository.dart |
ApiUris, Cabecalhos |
Infraestrutura | core/network/api_uris.dart |
HttpProdutoRepository |
Infraestrutura | features/cardapio/infra/repositories/http_produto_repository.dart |
CacheAdaptadorProdutoRepository |
Infraestrutura | features/cardapio/infra/repositories/cache_adaptador_produto_repository.dart |
| Hierarquia de exceções | Infraestrutura / Core | core/errors/app_exception.dart |
comRetry |
Infraestrutura / Core | core/network/retry.dart |
CardapioNotifier |
Apresentação | features/cardapio/presentation/notifiers/cardapio_notifier.dart |
TelaCardapio |
Apresentação | features/cardapio/presentation/pages/tela_cardapio.dart |
Com essa organização clara e as implementações que você estudou neste módulo, o seu Projeto Integrador está pronto para dar o próximo salto: conectar-se a um backend real, construído por você na nuvem AWS. Até lá, execute os exemplos, refatore-os para o contexto do seu projeto e chegue à aula presencial pronto para perguntar, discutir e construir.