Aula 13 · Semana 7 · Segunda, 19 de outubro de 2026 · 16:00 às 18:00
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ã.
ChangeNotifier.context.watch, context.read e Consumer.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
FutureBuilder da última aula, e funciona. Mas este ecrã sabe o caminho da API, sabe ler o JSON, guarda o Future e decide o que mostrar em cada estado.ApiClient trata do token, dos headers, dos códigos de estado e do jsonDecode. Não sabe o que é uma ilha./v2/islands? E se quiseres mostrar a app numa demonstração sem rede? E se o mapa também precisar das ilhas?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.
| Camada | Hoje, no nosso código | Faz | Só conhece |
|---|---|---|---|
| UI | IslandsScreen | Desenha o estado, chama ações | O notifier |
| Estado (notifier, ViewModel) | ForecastNotifier | A carregar, erro, dados; avisa o ecrã | O repository |
| Repository | ForecastRepository | Dá objetos da app (Island), venham de onde vierem | As fontes de dados |
| Data source | ApiClient | HTTP, token, JSON (e mais à frente, a base de dados local) | A rede, o disco |
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.
/// What the app can ask for. The screens only see this.
abstract class ForecastRepository {
Future<List<Island>> fetchIslands();
Future<Forecast> fetchForecast(String islandId);
}
abstract class sem corpo nos métodos.HttpForecastRepository pede à API; FakeForecastRepository devolve dados fixos; mais à frente, uma com cache no telemóvel.ForecastRepository e não sabe qual dos três é.fromJson converte a forma da API (o DTO) na forma da app (o modelo); se a API mudar um nome, muda o fromJson, e o resto da app fica igual.Para os testes: repository = uma interface com várias implementações; o ecrã depende da interface, nunca da implementação.
final ForecastRepository repository = useFake
? FakeForecastRepository()
: HttpForecastRepository(
ApiClient(clientId: clientId, clientSecret: clientSecret),
);
HttpForecastRepository recebe o ApiClient no construtor; o ForecastNotifier recebe o repository no construtor. Nenhum deles cria aquilo de que precisa.main, num só sítio. É aí que se escolhe a API ou o fake, e as credenciais.ApiClient() criado dentro do notifier ou uma variável global que todos usam, é uma dependência escondida: não se troca, e não se vê quem depende de quem.get_it, Riverpod); a regra do construtor é a mesma.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.
| Situação | Escolha |
|---|---|
| Estado de um só widget: um cartão aberto, um campo de pesquisa, um separador | setState |
| Dados da rede, ou estado partilhado por vários ecrãs, numa equipa pequena | ChangeNotifier + Provider (o projeto) |
| Muitos eventos, regras de teste fortes, equipas grandes | BLoC ou Riverpod |
ChangeNotifier por BLoC é mexer numa camada; sem elas, é reescrever ecrãs.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ça | O que faz | Onde se usa |
|---|---|---|
ChangeNotifier | Guarda o estado; notifyListeners() avisa quem está a ouvir | A classe do estado: ForecastNotifier extends ChangeNotifier |
ChangeNotifierProvider | Cria o notifier uma vez, põe-no na árvore, liberta-o no fim | Por 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 aviso | Dentro do build |
context.read<T>() | Lê sem subscrever | Em callbacks: onPressed, onTap |
Consumer<T> | Como o watch, mas só reconstrói o que está lá dentro | Num pedaço pequeno de um ecrã grande |
watch no build, read nos callbacks.InheritedWidget com menos cerimónia, o mesmo mecanismo do Theme.of(context): um valor posto em cima na árvore, que qualquer widget de baixo encontra pelo context.Para os testes: notifyListeners avisa; watch reconstrói; read não; o Provider tem de estar acima de quem o lê.
git clone https://github.com/Tiago-Rocha/pdm-aula-13.git
cd pdm-aula-13
flutter pub get
clientId e o clientSecret estão na issue #1 do teu repositório pdm-miniprojeto.flutter run -d chrome --dart-define=CLIENT_ID=pdm-ab12cd --dart-define=CLIENT_SECRET=o-teu-segredogit. No primeiro, onde corre a app, r é hot reload, R é hot restart e q fecha.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./// 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>);
}
}
fetchIslands é o _load do ecrã, mudado de sítio: o caminho e o fromJson vivem agora aqui.implements ForecastRepository obriga a escrever os dois métodos da interface, com @override. Se faltar um, o código não compila.fetchForecast fica pronto para mais à frente: hoje ninguém o chama.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),
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.
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
}
}
}
FutureBuilder passam a ser três campos: isLoading, error e islands.BuildContext. Importa foundation.dart, não material.dart.notifyListeners()? O que vê o utilizador ao carregar em "Tentar de novo" se tirares o primeiro?MaterialAppvoid 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(),
),
),
);
}
create corre uma vez: cria o notifier com o repository e o ..load() lança logo o primeiro pedido. Os .. chamam load() e devolvem o notifier.IslandsScreen deixou de receber parâmetros: vai buscar o notifier ao Provider.ChangeNotifierProvider o encontra. Por cima do MaterialApp, isso quer dizer todos os ecrãs da app.ChangeNotifierProvider ficasse dentro do home:, à volta do IslandsScreen?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
}
}
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.
watch e onde usaste read? O que acontecia se os trocasses?/// 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';
ForecastRepository e não sabe que este é a fingir.fail: true deixa ver o ecrã de erro sem desligar o Wi-Fi.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),
);
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=trueNa 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.
ForecastNotifier e no IslandsScreen?/// 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()),
],
id das ilhas, num Set: uma ilha é favorita ou não, sem repetições. O Set é privado; quem está fora só pode perguntar (contains, count) e pedir (toggle).MultiProvider é uma lista de providers em vez de vários encaixados uns nos outros. O child continua a ser o MaterialApp./// 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()),
),
),
),
Consumer e a estrela num widget só dela, em vez de um context.watch<FavoritesNotifier>() no build do IslandsScreen?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)
);
}
}
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.
ForecastRepository com a interface e o HttpForecastRepository, que recebe o ApiClient: o pedido e o JSON saem do ecrã (passo 1).ForecastNotifier com isLoading, error e islands, num ChangeNotifierProvider por cima do MaterialApp; o ecrã é um StatelessWidget com watch e read (passo 2).FakeForecastRepository: a mesma interface, sem API; o main escolhe qual usar e mais nada muda (passo 3).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.
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).ChangeNotifier e Provider passo a passo, com o exemplo do carrinho de compras.watch, read, Consumer e MultiProvider.