Aula 14 · Semana 7 · Terça, 20 de outubro de 2026 · 08:30 às 10:30
Teoria: falhas de rede, respostas 500 e 429, JSON inesperado; offline-first, cache HTTP e paginação. Prática: modelação dos quatro estados de cada ecrã, retry com backoff, pull-to-refresh e teste da app com o modo caos da API ligado.
ETag e Cache-Control, paginação e rate limiting.sealed class e tratá-lo com um switch exaustivo.SocketException em milissegundos.TimeoutException.null, string onde devia estar um número, lista vazia. FormatException ou TypeError.Para os testes: em mobile a falha de rede não é uma exceção, é um caso normal; desenha-se a app para ela.
connectivity_plus diz se há Wi-Fi ou dados, não se há Internet. A única prova é um pedido que responde.Para os testes: offline-first é cache primeiro, rede depois, e dizer sempre ao utilizador a idade dos dados.
ETag, Cache-Control, 304If-None-Match: "abc" no pedido seguinte. Se nada mudou, o servidor responde 304 Not Modified sem corpo.no-store proíbe guardar (dados sensíveis).max-age evita o pedido de todo.http não faz cache sozinho. Opções: dio + dio_cache_interceptor, ou a nossa base de dados local (aula 16).Para os testes: ETag + If-None-Match dão 304 "não mudou"; Cache-Control diz durante quanto tempo a resposta é válida.
| Paginação | Como | Prós e contras |
|---|---|---|
| Offset | ?page=3&size=20 | Simples; salta ou repete itens quando a lista muda entre pedidos |
| Cursor | ?after=eyJpZCI6NDJ9 | Estável (Twitter, Instagram); não permite saltar para a página 7 |
| Infinite scroll | Pedir a página seguinte quando o scroll se aproxima do fim | Natural em mobile; precisa de loader no fim da lista e de tratar o erro a meio |
Retry-After: 30.Retry-After. Repetir logo piora e pode levar a bloqueio do token.Para os testes: 429 pede espera, não retry imediato; Retry-After diz quanto tempo.
Retry-After).POST não idempotente: criar dois favoritos, pagar duas vezes.POST tem mesmo de ser repetido, usa uma chave de idempotência (Idempotency-Key), como o Stripe: o servidor ignora a repetição.Para os testes: repetir só falhas transitórias, só pedidos idempotentes, com backoff exponencial e jitter.
git clone https://github.com/pdm-daw-2026/pdm-aula-14.git
cd pdm-aula-14/starter
flutter pub get
flutter run
starter/ é a app de ontem (repository + notifier + Provider) com TODOs numerados de 1 a 5 e testes a falhar para cada estado.solucao/ é o estado final, e é o que vai aguentar o modo caos no fim da aula.pubspec.yaml: skeletonizer para o loading. Já está instalado.sealed classsealed class EstadoPrevisao {
const EstadoPrevisao();
}
class Carregando extends EstadoPrevisao {
const Carregando();
}
class Erro extends EstadoPrevisao {
const Erro(this.mensagem, {this.podeRepetir = true});
final String mensagem;
final bool podeRepetir;
}
class Vazio extends EstadoPrevisao {
const Vazio();
}
class Dados extends EstadoPrevisao {
const Dados(this.localidades, {required this.obtidoEm});
final List<Localidade> localidades;
final DateTime obtidoEm;
}
class PrevisaoNotifier extends ChangeNotifier {
PrevisaoNotifier(this._repo);
final PrevisaoRepository _repo;
EstadoPrevisao estado = const Carregando();
Future<void> carregar({bool silencioso = false}) async {
if (!silencioso) {
estado = const Carregando();
notifyListeners();
}
try {
final lista = await _repo.localidades();
estado = lista.isEmpty
? const Vazio()
: Dados(lista, obtidoEm: DateTime.now());
} on ApiException catch (e) {
estado = Erro(mensagemPara(e), podeRepetir: e.transitoria);
} catch (e) {
estado = Erro(mensagemPara(e));
}
notifyListeners();
}
}
switch exaustivo na UI@override
Widget build(BuildContext context) {
final estado = context.watch<PrevisaoNotifier>().estado;
return switch (estado) {
Carregando() => const ListaSkeleton(),
Vazio() => EstadoVazio(
mensagem: 'Ainda não há localidades.',
acao: 'Atualizar',
aoAgir: () => context.read<PrevisaoNotifier>().carregar(),
),
Erro(:final mensagem, :final podeRepetir) => EstadoErro(
mensagem: mensagem,
aoRepetir: podeRepetir
? () => context.read<PrevisaoNotifier>().carregar()
: null,
),
Dados(:final localidades, :final obtidoEm) => ListaLocalidades(
localidades: localidades,
obtidoEm: obtidoEm,
),
};
}
sealed fecha a hierarquia: só estas quatro subclasses existem. O compilador sabe-o e obriga o switch a tratar todas.Parcial e o build deixa de compilar até o tratares. É impossível esquecer um caso.aCarregar, erro, lista) havia oito combinações, quatro delas impossíveis. Agora há quatro estados, todos possíveis.Erro(:final mensagem) é um object pattern: extrai o campo no próprio case.Dados.localidades): não há lista "pendurada" quando o estado é Erro.DetalheNotifier para EstadoDetalhe com os mesmos quatro casos e um switch exaustivo no ecrã de detalhe. O teste test/estado_detalhe_test.dart verifica cada transição.class EstadoErro extends StatelessWidget {
const EstadoErro({super.key, required this.mensagem, this.aoRepetir});
final String mensagem;
final VoidCallback? aoRepetir;
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.cloud_off, size: 48),
const SizedBox(height: 12),
Text(mensagem, textAlign: TextAlign.center),
if (aoRepetir != null) ...[
const SizedBox(height: 12),
FilledButton.icon(
onPressed: aoRepetir,
icon: const Icon(Icons.refresh),
label: const Text('Tentar de novo'),
),
],
],
),
);
}
}
aoRepetir nulo esconde-o). Um 404 não se repete.Skeletonizer(enabled: true, child: ListaLocalidades(localidades: Localidade.exemplo)) desenha a forma da lista a cinzento.lib/core/widgets/: cada ecrã do projeto usa os mesmos.EstadoVazio (ícone, mensagem, botão opcional) e ListaSkeleton com skeletonizer. Usa-os no ecrã de avisos: sem avisos ativos mostra o vazio com a ação "Atualizar".ApiClientFuture<dynamic> get(String caminho, {int tentativas = 3}) async {
var espera = const Duration(seconds: 1);
for (var i = 1; ; i++) {
try {
return await _getUmaVez(caminho); // o get da aula 12
} on ApiException catch (e) {
if (!e.transitoria || i >= tentativas) rethrow;
await Future.delayed(e.retryAfter ?? _comJitter(espera));
} on TimeoutException catch (_) {
if (i >= tentativas) rethrow;
await Future.delayed(_comJitter(espera));
} on SocketException catch (_) {
if (i >= tentativas) rethrow;
await Future.delayed(_comJitter(espera));
}
espera *= 2; // 1 s, 2 s, 4 s
}
}
Duration _comJitter(Duration base) =>
base + Duration(milliseconds: Random().nextInt(300));
// Em ApiException: 429 e 5xx são transitórios; 4xx não
bool get transitoria => statusCode == 429 || (statusCode ?? 0) >= 500;
String mensagemPara(Object erro) => switch (erro) {
SocketException() => 'Sem ligação. Verifica a rede e tenta de novo.',
TimeoutException() => 'O servidor está a demorar. Tenta daqui a pouco.',
ApiException(statusCode: 429) => 'Muitos pedidos seguidos. Espera uns segundos.',
ApiException(statusCode: 401) => 'A sessão expirou. Volta a entrar.',
ApiException(statusCode: final c?) when c >= 500 =>
'O serviço está com problemas. Mostramos os últimos dados que temos.',
FormatException() => 'Recebemos dados inesperados. Já estamos a tratar disso.',
_ => 'Algo correu mal. Tenta de novo.',
};
get repete: post e delete falham à primeira, porque não são idempotentes.retryAfter vem do header Retry-After em segundos; o starter já o lê em _getUmaVez.MockClient que responde 503, 503 e depois 200: get tem de devolver os dados e ter feito exatamente três pedidos. Depois um que responde 404: um só pedido e ApiException.class ListaLocalidades extends StatelessWidget {
const ListaLocalidades({super.key, required this.localidades, required this.obtidoEm});
final List<Localidade> localidades;
final DateTime obtidoEm;
@override
Widget build(BuildContext context) {
final minutos = DateTime.now().difference(obtidoEm).inMinutes;
return RefreshIndicator(
onRefresh: () => context.read<PrevisaoNotifier>().carregar(silencioso: true),
child: ListView.builder(
physics: const AlwaysScrollableScrollPhysics(),
itemCount: localidades.length + 1,
itemBuilder: (_, i) {
if (i == 0) {
return ListTile(
dense: true,
leading: const Icon(Icons.schedule, size: 18),
title: Text(minutos == 0 ? 'Atualizado agora' : 'Dados de há $minutos min'),
);
}
return ListTile(title: Text(localidades[i - 1].nome), subtitle: Text(localidades[i - 1].ilha));
},
),
);
}
}
RefreshIndicator chama onRefresh e mantém o círculo visível até o Future terminar. Por isso carregar devolve Future<void>.silencioso: true não passa por Carregando: a lista fica visível durante o refresh. Sem isto, o skeleton substituía a lista e o gesto parecia partido.AlwaysScrollableScrollPhysics permite puxar mesmo quando a lista cabe no ecrã.obtidoEm viver no estado. É por isso que está em Dados, não no widget.SnackBar e mantém os dados: nunca troca dados válidos por um ecrã de erro.carregar(silencioso: true) falha e o estado atual é Dados, mantém os dados e guarda a mensagem num campo avisoRefresh. O ecrã mostra-a num SnackBar uma só vez.Retry-After: 5.README do starter três coisas que corrigiste com o modo caos ligado e o que o utilizador via antes e depois. Traz esta lista para a aula de amanhã: é o padrão que vais aplicar a todos os ecrãs do projeto.switch exaustivo.mensagemPara.sealed, o switch exaustivo e os object patterns que usámos.