PDM · DAW 2

Aula 13 · Semana 7 · Segunda, 19 de outubro de 2026 · 16:00 às 18:00

Arquitetura por camadas e gestão de estado

Hoje a app vai buscar as ilhas à API do curso, escrita por ti. Partimos de um ecrã que faz tudo sozinho (pedido, JSON e estados) e arrumamos cada coisa na sua camada: um repository para os dados, um notifier para o estado e o Provider para os ligar ao ecrã.

Repositório: pdm-aula-13

No fim desta aula consegues

O ecrã que faz tudo

class _IslandsScreenState extends State<IslandsScreen> {
  late Future<List<Island>> _islands;

  @override
  void initState() {
    super.initState();
    _islands = _load();
  }

  Future<List<Island>> _load() async {
    final list = await widget.api.get('/v1/islands') as List<dynamic>;
    return list
        .map((item) => Island.fromJson(item as Map<String, dynamic>))
        .toList();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Tempo Açores')),
      body: FutureBuilder<List<Island>>(
        future: _islands,
        builder: (context, snapshot) {
          // a carregar, erro e dados, como na última aula
Pergunta à turma Que ficheiros mudas se a API mudar o caminho para /v2/islands? E se quiseres mostrar a app numa demonstração sem rede? E se o mapa também precisar das ilhas?

Porquê camadas

Numa app grande (a do banco, a do Spotify) o ecrã pede "a conta" e não sabe se ela veio da rede, da cache ou do disco.

Para os testes: as camadas existem para isolar mudanças; o ecrã não deve conhecer HTTP nem JSON.

UI, notifier, repository e data source

UI · IslandsScreen mostra o estado e chama load(); só conhece o notifier Estado · ForecastNotifier isLoading, error, islands; só conhece o repository Dados · ForecastRepository (interface) devolve Island e Forecast, nunca JSON HttpForecastRepository ApiClient: HTTP, token, JSON FakeForecastRepository dados fixos, sem API main.dart cria as peças, de baixo para cima, e entrega cada uma à de cima pelo construtor quem usa não cria
Cada camada só conhece a de baixo. Como o repository é uma interface, a de baixo pode ser a API ou um fake, e as de cima não mudam.
CamadaHoje, no nosso códigoFazSó conhece
UIIslandsScreenDesenha o estado, chama açõesO notifier
Estado (notifier, ViewModel)ForecastNotifierA carregar, erro, dados; avisa o ecrãO repository
RepositoryForecastRepositoryDá objetos da app (Island), venham de onde vieremAs fontes de dados
Data sourceApiClientHTTP, token, JSON (e mais à frente, a base de dados local)A rede, o disco
Pergunta à turma O IslandsScreen pode chamar o ApiClient? O ForecastRepository pode mostrar um SnackBar?

Para os testes: as dependências apontam só para baixo; o repository é a fronteira entre a app e os dados.

Repository: uma interface, várias implementações

/// What the app can ask for. The screens only see this.
abstract class ForecastRepository {
  Future<List<Island>> fetchIslands();
  Future<Forecast> fetchForecast(String islandId);
}
Analogia O balcão de uma loja. Pedes "uma previsão do Pico" ao balcão e não sabes se veio do armazém (a cache) ou do fornecedor (a API). A loja pode mudar de fornecedor sem tu dares por isso; e numa formação, um balcão a fingir responde sempre o mesmo.

Para os testes: repository = uma interface com várias implementações; o ecrã depende da interface, nunca da implementação.

Injeção de dependências: quem usa não cria

final ForecastRepository repository = useFake
    ? FakeForecastRepository()
    : HttpForecastRepository(
        ApiClient(clientId: clientId, clientSecret: clientSecret),
      );
Pergunta à turma Se o HttpForecastRepository criasse o seu próprio ApiClient lá dentro, o que deixava de ser possível?

Para os testes: injeção de dependências = receber pelo construtor em vez de criar; permite trocar uma peça sem mexer em quem a usa.

Quando chega o Provider

SituaçãoEscolha
Estado de um só widget: um cartão aberto, um campo de pesquisa, um separadorsetState
Dados da rede, ou estado partilhado por vários ecrãs, numa equipa pequenaChangeNotifier + Provider (o projeto)
Muitos eventos, regras de teste fortes, equipas grandesBLoC ou Riverpod
Pergunta à turma O cartão que abre e fecha precisa de Provider? E a lista de ilhas favoritas, que aparece na lista e num ecrã de favoritas?

Para os testes: a arquitetura certa é a mais simples que resolve o problema de hoje, com fronteiras que a deixam crescer.

ChangeNotifier e Provider: as peças

PeçaO que fazOnde se usa
ChangeNotifierGuarda o estado; notifyListeners() avisa quem está a ouvirA classe do estado: ForecastNotifier extends ChangeNotifier
ChangeNotifierProviderCria o notifier uma vez, põe-no na árvore, liberta-o no fimPor cima de todos os ecrãs que o leem: hoje, por cima do MaterialApp
context.watch<T>()Lê e subscreve: o widget volta a correr o build a cada avisoDentro do build
context.read<T>()Lê sem subscreverEm callbacks: onPressed, onTap
Consumer<T>Como o watch, mas só reconstrói o que está lá dentroNum pedaço pequeno de um ecrã grande

Para os testes: notifyListeners avisa; watch reconstrói; read não; o Provider tem de estar acima de quem o lê.

Começar: o repositório de hoje

git clone https://github.com/Tiago-Rocha/pdm-aula-13.git
cd pdm-aula-13
flutter pub get
  1. Abre a pasta no VS Code. O clientId e o clientSecret estão na issue #1 do teu repositório pdm-miniprojeto.
  2. No terminal do VS Code, numa só linha, com os teus valores: flutter run -d chrome --dart-define=CLIENT_ID=pdm-ab12cd --dart-define=CLIENT_SECRET=o-teu-segredo
  3. Tens de ver a lista das ilhas, cada uma com a fotografia. Mão no ar quando aparecer.
  4. Abre um segundo terminal (o + do painel do terminal) para os comandos git. No primeiro, onde corre a app, r é hot reload, R é hot restart e q fecha.
Aparece 401: clientId ou clientSecret errados.? Faltam os dois --dart-define ou têm um erro. O F5 do VS Code não os passa: corre pelo terminal. As credenciais nunca vão para o código nem para o git.

1. O repository: o pedido sai do ecrã

/// How: HTTP to the course API, through the ApiClient.
class HttpForecastRepository implements ForecastRepository {
  HttpForecastRepository(this._api); // injected, not created here

  final ApiClient _api;

  @override
  Future<List<Island>> fetchIslands() async {
    final list = await _api.get('/v1/islands') as List<dynamic>;
    return list
        .map((item) => Island.fromJson(item as Map<String, dynamic>))
        .toList();
  }

  @override
  Future<Forecast> fetchForecast(String islandId) async {
    final json = await _api.get('/v1/islands/$islandId/forecast');
    return Forecast.fromJson(json as Map<String, dynamic>);
  }
}

Passo 1: o ecrã recebe o repository

import 'forecast_repository.dart';
import 'models.dart';

  const IslandsScreen({super.key, required this.repository});

  final ForecastRepository repository;

// no initState, e no botão Tentar de novo dentro do setState:
    _islands = widget.repository.fetchIslands();
  final api = ApiClient(clientId: clientId, clientSecret: clientSecret);
  final repository = HttpForecastRepository(api);

      home: IslandsScreen(repository: repository),
Faz Cria lib/forecast_repository.dart com a interface e o HttpForecastRepository. No ecrã, o campo api passa a repository, o _load sai e as duas chamadas passam a widget.repository.fetchIslands(). No main, cria o repository e passa-o ao ecrã.

Na app: a mesma lista de ilhas, e é esse o objetivo. O ecrã já não sabe caminhos nem JSON.

2. ForecastNotifier: o estado sai do ecrã

/// The state of the islands screen. Knows the repository, not the widgets.
class ForecastNotifier extends ChangeNotifier {
  ForecastNotifier(this._repository); // injected, not created here

  final ForecastRepository _repository;

  bool isLoading = false;
  String? error;
  List<Island> islands = const [];

  Future<void> load() async {
    isLoading = true;
    error = null;
    notifyListeners(); // the screen shows the spinner
    try {
      islands = await _repository.fetchIslands();
    } catch (e) {
      error = '$e';
    } finally {
      isLoading = false;
      notifyListeners(); // the screen shows the islands or the error
    }
  }
}
Pergunta à turma Porque há dois notifyListeners()? O que vê o utilizador ao carregar em "Tentar de novo" se tirares o primeiro?

2. O Provider fica por cima do MaterialApp

void main() {
  final api = ApiClient(clientId: clientId, clientSecret: clientSecret);
  final repository = HttpForecastRepository(api);
  runApp(
    ChangeNotifierProvider(
      create: (_) => ForecastNotifier(repository)..load(),
      child: MaterialApp(
        title: 'Tempo Açores',
        theme: ThemeData(colorSchemeSeed: Colors.teal),
        home: const IslandsScreen(),
      ),
    ),
  );
}
Pergunta à turma E se o ChangeNotifierProvider ficasse dentro do home:, à volta do IslandsScreen?

Passo 2: o ecrã só conhece o notifier

git stash -u
git checkout passo-1
flutter pub get
class IslandsScreen extends StatelessWidget {
  const IslandsScreen({super.key});

  @override
  Widget build(BuildContext context) {
    final forecast = context.watch<ForecastNotifier>(); // rebuilds on notify
    return Scaffold(
      appBar: AppBar(title: const Text('Tempo Açores')),
      body: _body(context, forecast),
    );
  }

  Widget _body(BuildContext context, ForecastNotifier forecast) {
    if (forecast.isLoading) {
      return const Center(child: CircularProgressIndicator());
    }
    // o erro: o Center de antes, com Text(forecast.error!) e
    onPressed: () => context.read<ForecastNotifier>().load(),
    // os dados: o ListView.builder de antes, com forecast.islands
  }
}
Faz Cria lib/forecast_notifier.dart e muda o main como nos slides anteriores. O ecrã passa a StatelessWidget: apaga o State, o Future e o FutureBuilder, e os três ramos passam para o _body.

Na app: a mesma lista, outra vez. A diferença está no código: o ecrã ficou sem State, sem Future e sem FutureBuilder, e o estado vive no notifier.

Pergunta à turma Onde usaste watch e onde usaste read? O que acontecia se os trocasses?

3. Um repository a fingir

/// Same interface, fixed data: no API and no credentials.
class FakeForecastRepository implements ForecastRepository {
  FakeForecastRepository({this.fail = false});

  final bool fail; // true: shows the error screen

  @override
  Future<List<Island>> fetchIslands() async {
    await Future.delayed(const Duration(seconds: 1)); // a slow network
    if (fail) throw Exception('Sem rede (fake)');
    return const [
      Island(id: 'corvo', name: 'Corvo', imageUrl: '$_img/corvo.jpg'),
      Island(id: 'faial', name: 'Faial', imageUrl: '$_img/faial.jpg'),
      Island(id: 'pico', name: 'Pico', imageUrl: '$_img/pico.jpg'),
    ];
  }

  @override
  Future<Forecast> fetchForecast(String islandId) {
    throw UnimplementedError(); // not used yet
  }
}

const _img = 'https://api-pdm.sublimecipher.com/static/islands';

Passo 3: trocar a API pelo fake

git stash -u
git checkout passo-2
flutter pub get
const useFake = bool.fromEnvironment('FAKE'); // --dart-define=FAKE=true

  final ForecastRepository repository = useFake
      ? FakeForecastRepository()
      : HttpForecastRepository(
          ApiClient(clientId: clientId, clientSecret: clientSecret),
        );
Faz Cria lib/fake_forecast_repository.dart com o código do slide anterior e os imports de forecast_repository.dart e models.dart. No main, as duas linhas do api e do repository dão lugar a esta escolha. Fecha a app (q) e corre: flutter run -d chrome --dart-define=FAKE=true

Na app: um segundo a carregar e depois três ilhas, sem credenciais. Muda para FakeForecastRepository(fail: true), R: aparece Exception: Sem rede (fake), e "Tentar de novo" mostra o "a carregar" antes de voltar ao erro.

Pergunta à turma Para trocar a API pelo fake, quantas linhas mudaste no ForecastNotifier e no IslandsScreen?

4. Favoritas: estado partilhado por dois ecrãs

/// Favourite islands, in memory. Shared by two screens.
class FavoritesNotifier extends ChangeNotifier {
  final Set<String> _ids = {};

  int get count => _ids.length;
  bool contains(String islandId) => _ids.contains(islandId);

  void toggle(String islandId) {
    if (!_ids.remove(islandId)) _ids.add(islandId);
    notifyListeners();
  }
}
    MultiProvider(
      providers: [
        ChangeNotifierProvider(
          create: (_) => ForecastNotifier(repository)..load(),
        ),
        ChangeNotifierProvider(create: (_) => FavoritesNotifier()),
      ],
Pergunta à turma Marca duas favoritas, fecha a app e volta a abri-la. Ainda lá estão? E com um hot reload?

4. A estrela e o contador

/// The star of one island. Used by both screens.
class FavoriteButton extends StatelessWidget {
  const FavoriteButton({super.key, required this.islandId});

  final String islandId;

  @override
  Widget build(BuildContext context) {
    final favorite = context.watch<FavoritesNotifier>().contains(islandId);
    return IconButton(
      icon: Icon(favorite ? Icons.star : Icons.star_border),
      onPressed: () => context.read<FavoritesNotifier>().toggle(islandId),
    );
  }
}
Consumer<FavoritesNotifier>(
  builder: (context, favorites, child) => IconButton(
    icon: Badge(
      isLabelVisible: favorites.count > 0,
      label: Text('${favorites.count}'),
      child: const Icon(Icons.star),
    ),
    onPressed: () => Navigator.push(
      context,
      MaterialPageRoute(builder: (_) => const FavoritesScreen()),
    ),
  ),
),
Pergunta à turma Porque é que o contador está num Consumer e a estrela num widget só dela, em vez de um context.watch<FavoritesNotifier>() no build do IslandsScreen?

Passo 4: o ecrã das favoritas

git stash -u
git checkout passo-3
flutter pub get
class FavoritesScreen extends StatelessWidget {
  const FavoritesScreen({super.key});

  @override
  Widget build(BuildContext context) {
    final favorites = context.watch<FavoritesNotifier>();
    final islands = context
        .watch<ForecastNotifier>()
        .islands
        .where((island) => '...')
        .toList();
    return Scaffold(
      appBar: AppBar(title: const Text('Favoritas')),
      // body: sem favoritas, 'Ainda não há ilhas favoritas.' ao centro;
      // com favoritas, uma ListView com um ListTile por ilha:
      // o nome no title e trailing: FavoriteButton(islandId: island.id)
    );
  }
}
Exercício Cria lib/favorites_notifier.dart e regista-o no MultiProvider, no sítio do TODO 4 do main. Em lib/favorites_screen.dart, o FavoriteButton do slide anterior e este ecrã: escreve a condição do where no lugar do '...' e o body. No IslandsScreen, o Consumer nas actions e trailing: FavoriteButton(islandId: forecast.islands[i].id) em cada ilha.

Na app: marcas o Pico e o Corvo e o contador mostra 2. A estrela de cima abre as Favoritas com os dois; tiras o Corvo lá, voltas atrás: na lista o Corvo já não tem estrela e o contador mostra 1.

Onde ficámos

  1. ForecastRepository com a interface e o HttpForecastRepository, que recebe o ApiClient: o pedido e o JSON saem do ecrã (passo 1).
  2. ForecastNotifier com isLoading, error e islands, num ChangeNotifierProvider por cima do MaterialApp; o ecrã é um StatelessWidget com watch e read (passo 2).
  3. FakeForecastRepository: a mesma interface, sem API; o main escolhe qual usar e mais nada muda (passo 3).
  4. FavoritesNotifier partilhado pela lista e pelas Favoritas, com MultiProvider, Consumer e um FavoriteButton (passo 4).

O branch passo-4 é o estado final: compara o teu código com ele.

Até amanhã

Para fazer

  • No passo-4, tocar numa ilha abre a previsão dela a 5 dias. Mesmas camadas: o fetchForecast já existe; escreve um IslandForecastNotifier (isLoading, error, forecast, load) e um ecrã que só conhece o notifier. Pista: este notifier é de uma ilha só, por isso o seu ChangeNotifierProvider fica dentro da rota, à volta do ecrã novo; e o repository entra no MultiProvider com Provider<ForecastRepository>.value(value: repository).
  • Lê o guia de arquitetura do Flutter e anota uma diferença em relação ao que fizemos hoje.

Próxima aula

  • Amanhã, terça, 20 de outubro, das 08:30 às 10:30: o que fazer quando a rede falha. Estados de erro, vazio e a carregar, tentar de novo, e a API em modo caos.

Para ler