PDM · DAW 2

Aula 14 · Semana 7 · Terça, 20 de outubro de 2026 · 08:30 às 10:30

Resiliência: estados de UI, retry e modo caos

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.

Repositório: pdm-aula-14

No fim desta aula consegues

Tudo falha

Para os testes: em mobile a falha de rede não é uma exceção, é um caso normal; desenha-se a app para ela.

Analogia Um balcão tem sempre um de quatro cartazes à vista: "a preparar", "não há nada", "avariado, tente de novo", ou o prato em cima do balcão. Nunca fica sem cartaz. Um ecrã é igual: está a carregar, está vazio, falhou ou tem dados; o ecrã em branco é o balcão sem cartaz nenhum.

Offline-first

Para os testes: offline-first é cache primeiro, rede depois, e dizer sempre ao utilizador a idade dos dados.

Cache HTTP: ETag, Cache-Control, 304

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 e rate limiting

PaginaçãoComoPrós e contras
Offset?page=3&size=20Simples; salta ou repete itens quando a lista muda entre pedidos
Cursor?after=eyJpZCI6NDJ9Estável (Twitter, Instagram); não permite saltar para a página 7
Infinite scrollPedir a página seguinte quando o scroll se aproxima do fimNatural em mobile; precisa de loader no fim da lista e de tratar o erro a meio

Para os testes: 429 pede espera, não retry imediato; Retry-After diz quanto tempo.

Retry com backoff exponencial e jitter

Para os testes: repetir só falhas transitórias, só pedidos idempotentes, com backoff exponencial e jitter.

Repositório da aula

git clone https://github.com/pdm-daw-2026/pdm-aula-14.git
cd pdm-aula-14/starter
flutter pub get
flutter run
Aquecimento Liga o modo avião e abre a app do starter. Anota em duas linhas o que vês e o que devias ver. É o teste de aceitação do fim da aula.

1. O estado como sealed class

Carregando spinner, sem dados Dados lista + obtidoEm Vazio sem resultados Erro mensagem, podeRepetir com itens sem itens falhou ou timeout pull-to-refresh: recarrega, mantém os dados antigos no ecrã tentar de novo (se podeRepetir)
Um só campo de estado, quatro casos. O switch exaustivo na UI garante que nenhum fica por desenhar.
sealed 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;
}

1. O notifier passa a ter um só campo

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();
  }
}

1. 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,
      ),
  };
}

1. Estados: o que interessa

Exercício 1 Migra o 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.

2. Widgets de erro, vazio e loading

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'),
            ),
          ],
        ],
      ),
    );
  }
}

2. Estados visuais: o que interessa

Exercício 2 Cria 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".

3. Retry com backoff no ApiClient

Future<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;

3. Mensagens humanas por tipo de falha

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.',
    };
Exercício 3 Acrescenta um teste com 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.

4. Pull-to-refresh e idade dos dados

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));
        },
      ),
    );
  }
}

4. Refresh: o que interessa

Exercício 4 Quando 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.

5. Modo caos: observar, corrigir, repetir

  1. O docente liga o modo caos ao teu grupo: latência de 3 s em metade dos pedidos, 20% de respostas 500, um 429 a cada dez pedidos com Retry-After: 5.
  2. Usa a app durante dois minutos como um utilizador: abre a lista, entra num detalhe, puxa para atualizar, volta. Anota tudo o que vês e não devias ver.
  3. Corrige por ordem de gravidade: crash ou ecrã em branco, depois mensagens cruas, depois retry a bater no 429, depois dados válidos substituídos por erro.
  4. Repete até a app aguentar cinco minutos de caos sem crash, sem ecrã em branco e sem mensagem técnica.
Exercício 5 Regista no 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.

Até amanhã

Para fazer

  • Deixa todos os ecrãs da app (localidades, detalhe, avisos) com os quatro estados tratados por switch exaustivo.
  • Escreve três mensagens de erro humanas para três falhas diferentes (sem rede, 500, JSON inválido) e põe-nas em mensagemPara.
  • Amanhã arranca o projeto: vem com o grupo pensado (2 a 3 pessoas).

Para ler

  • Fetch data from the internet: o cookbook oficial, em especial a secção sobre tratar erros e o que mostrar ao utilizador.
  • MDN: HTTP status codes: cada código com o significado e o que o cliente deve fazer; 304, 429 e 5xx são os desta aula.
  • Sealed classes e patterns no dart.dev: o sealed, o switch exaustivo e os object patterns que usámos.
  • skeletonizer: loading com a forma do conteúdo, em vez de um spinner ao centro.