PDM · DAW 2

Aula 08 · Semana 5 · Terça, 6 de outubro de 2026 · 08:30 às 11:30

Estado: do setState às arquiteturas

Teoria: o que é estado numa app, onde deve viver, e comparação entre setState, Provider, BLoC e Riverpod. Prática, no ecrã da última aula: um cartão que abre e fecha, a unidade °C/°F, uma pesquisa na grelha das ilhas e um formulário com validação.

Repositório: pdm-aula-08

No fim desta aula consegues

Estado efémero vs estado de aplicação

Captura do ecrã de previsão com cinco widgets contornados: o ForecastScreen inteiro, CurrentConditions, a fila dos dias, o DetailCard e a pesquisa com a grelha das ilhas. 1 2 3 4 5
  1. 1
    ForecastScreen o ecrã inteiro, pai de todos os outros.
    guarda a unidade °C/°F
  2. 2
    CurrentConditions a temperatura de agora.
    lê a unidade
  3. 3
    DaysRow → DayChip os cinco dias.
    lê a unidade
  4. 4
    DetailCard mínima, máxima, vento e chuva.
    efémero: _expandedlê a unidade
  5. 5
    IslandsGrid a pesquisa e a grelha das ilhas.
    efémero: _search
  6. AlertsForm mais abaixo, fora do ecrã.
    efémero: o que está escrito
Laranja: estado efémero, só aquele widget precisa dele. Verde: lê a unidade, que vários widgets partilham, por isso é estado de aplicação.
Pergunta à turma E noutras apps? O carrinho de uma loja online, o separador escolhido numa barra de tabs, o utilizador com sessão iniciada, o texto meio escrito numa mensagem: qual é qual?

Para os testes: estado efémero fica no widget com setState; estado de aplicação fica fora da árvore e é partilhado.

O problema de passar por parâmetro

O ecrã em Fahrenheit. O ForecastScreen guarda _fahrenheit; o CurrentConditions recebe-o e mostra 73 °F; a fila dos dias e o DetailCard, a tracejado, continuam em °C. 1 2 3 4
  1. 1
    ForecastScreen guarda _fahrenheit e muda-o com setState quando tocas em °F.
  2. 2
    CurrentConditions recebe fahrenheit no construtor e mostra 73 °F.
    feito no passo 2
  3. 3
    DaysRow para o DayChip mostrar °F, a DaysRow tinha de receber fahrenheit só para o passar adiante.
    só passa adiante
  4. 4
    DetailCard ainda em °C: mais um construtor a mudar.
Cada widget que mostra temperaturas precisa de um parâmetro novo, e quem está no meio também.

A resposta a este problema é a razão de existirem as bibliotecas de gestão de estado. Todas resolvem a mesma coisa: pôr o estado num sítio acessível e reconstruir só quem o lê.

setState: quando chega e quando deixa de chegar

Animação: um toque no cartão de detalhe faz piscar só o cartão, que abre. rebuild: só o DetailCard

setState no DetailCard (passo 1): só o cartão volta a correr o build.

Animação: um toque em °F faz piscar o ecrã inteiro, que passa a mostrar 73 °F. rebuild: o ecrã todo

setState no ForecastScreen (passo 2): o ecrã inteiro volta a correr o build, até a grelha e o formulário.

Para os testes: setState marca o widget como sujo e agenda um rebuild do seu subtree no próximo frame. Não reconstrói a app inteira, mas reconstrói tudo abaixo.

ChangeNotifier, Provider e InheritedWidget

class UnitSettings extends ChangeNotifier {
  bool fahrenheit = false;

  void toggle() {
    fahrenheit = !fahrenheit;
    notifyListeners(); // avisa todos os widgets que fazem watch
  }
}

// num widget que mostra temperaturas: reconstrói quando a unidade muda
final fahrenheit = context.watch<UnitSettings>().fahrenheit;

// no botão °C/°F: só chama, não precisa de reconstruir
onSelectionChanged: (_) => context.read<UnitSettings>().toggle(),

Para os testes: Provider é um InheritedWidget com açúcar; ChangeNotifier avisa os ouvintes; watch reconstrói, read não.

BLoC, Cubit e Riverpod

AbordagemO que acrescentaQuando faz sentido
setStateNada: é do próprio FlutterEcrã isolado, poucos widgets, protótipos
Provider + ChangeNotifierUm objeto observável na árvore; watch reconstrói só quem lêVários ecrãs partilham estado; equipas pequenas; o nosso projeto
BLoC / CubitEstados imutáveis num Stream; cada transição é previsível e testável; mais ficheirosEquipas grandes, requisitos fortes de teste, apps bancárias
RiverpodProviders globais e tipados, sem BuildContext; erros apanhados em compilaçãoQuem já domina Provider e quer mais segurança

Todas fazem a mesma coisa por baixo: um objeto que guarda estado e avisa quem o observa. A diferença está na cerimónia e nas garantias.

Analogia Onde fica a lista de compras. Um post-it no frigorífico de um quarto: só quem entra nesse quarto o vê (setState). Um quadro na cozinha que toda a casa consulta e que avisa quando muda (ChangeNotifier com Provider). Um livro de registos com todas as alterações, datadas e assinadas (BLoC: eventos e estados). O quadro chega para a maioria das casas.

Fluxo unidirecional e imutabilidade

1Ação
Ecrã em °C, com um toque no botão °F.
O utilizador toca em °F.
2Estado
_fahrenheitfalse → true
Muda num só sítio: o State do ForecastScreen, com setState.
3UI
Ecrã em °F depois do rebuild: 73 °F.
O build corre outra vez e desenha 73 °F.
// 1. ação: o toque chama o callback, que pede a mudança
onSelectionChanged: (s) => setState(() => _fahrenheit = s.first),

// 2. estado: o valor existe num só sítio, no State do ForecastScreen
bool _fahrenheit = false; // the state lives in the State, not in the widget

// 3. UI: o build volta a correr e o valor novo desce por parâmetro
child: CurrentConditions(fahrenheit: _fahrenheit),

Para os testes: fluxo unidirecional é ação → estado → UI; imutabilidade significa criar cópias com copyWith em vez de alterar objetos.

Critério para escolher, e o que vamos usar

Quantos widgets precisam deste estado? setState cartão, pesquisa, formulário Equipa grande ou regras de teste fortes? Provider o nosso projeto BLoC / Riverpod apps grandes um vários não sim
Duas perguntas chegam para a maior parte das decisões. As setas coloridas são as escolhas do projeto.
Hoje só setState. Provider entra na aula 13, quando tivermos dados da API para partilhar.

Começar: o repositório de hoje

git clone https://github.com/Tiago-Rocha/pdm-aula-08.git
cd pdm-aula-08
flutter pub get
  1. Abre a pasta pdm-aula-08 no VS Code. É a app do fim da aula 7, mais um formulário de avisos no fundo do ecrã. Trabalhas em lib/forecast_screen.dart e lib/alerts_form.dart, nos comentários TODO 1 a TODO 4.
  2. Ctrl+Shift+P → Flutter: Select Device → o teu Android ou, se ainda não corre no teu portátil, o Chrome. Depois F5.
  3. Confirma o que vês: o ecrã de previsão da última aula, com o cartão de detalhe com as quatro linhas e, no fundo, o cartão "Receber avisos".

O Android não arranca? Quase sempre é o Java que o Gradle está a usar: vê os problemas frequentes do guia de instalação e chama o docente. Entretanto segue a aula no Chrome: todos os passos de hoje funcionam igual no browser.

1. StatelessWidget vs StatefulWidget

class DetailCard extends StatefulWidget {
  const DetailCard({super.key});

  @override
  State<DetailCard> createState() => _DetailCardState();
}

class _DetailCardState extends State<DetailCard> {
  bool _expanded = false;

  @override
  Widget build(BuildContext context) {
    // ... o build de antes, que agora pode ler e mudar _expanded
  }
}
Pergunta à turma Porque é que _expanded fica no _DetailCardState e não no DetailCard? O que acontece se fizeres _expanded = !_expanded sem setState?

Passo 1: o cartão que abre e fecha

// Pista: Ctrl+. em cima de "DetailCard" → Convert to StatefulWidget
bool _expanded = false;

return Card(
  clipBehavior: Clip.antiAlias, // keeps the ripple inside the corners
  child: InkWell(
    onTap: () => setState(() => _expanded = !_expanded),
    child: Padding(
      padding: const EdgeInsets.all(16),
      child: Column(
        children: [
          // Mínima e Máxima, como estão
          if (_expanded) ...[
            // Vento e Chuva, cada um com o SizedBox antes
          ],
          Icon(_expanded ? Icons.expand_less : Icons.expand_more),
        ],
      ),
    ),
  ),
);
Faz Transforma o DetailCard num StatefulWidget com o atalho do VS Code, acrescenta o campo _expanded, embrulha o Padding num InkWell e põe as linhas do vento e da chuva dentro do if (_expanded) ...[ ].

Na app: o cartão começa só com a mínima e a máxima e uma seta para baixo; um toque mostra o vento e a chuva e a seta vira para cima; outro toque fecha.

2. Subir o estado: a unidade °C/°F

Pergunta à turma Depois deste passo, o CurrentConditions mostra °F. E a fila dos dias e o cartão de detalhe? O que teríamos de mudar para o DayChip também mostrar °F?

Passo 2: o botão °C/°F

git stash -u
git checkout passo-1
flutter pub get
// Pista: Ctrl+. em cima de "ForecastScreen" → Convert to StatefulWidget
bool _fahrenheit = false; // the state lives in the State, not in the widget

appBar: AppBar(
  title: const Text('Tempo Açores'),
  actions: [
    SegmentedButton<bool>(
      segments: const [
        ButtonSegment(value: false, label: Text('°C')),
        ButtonSegment(value: true, label: Text('°F')),
      ],
      selected: {_fahrenheit},
      showSelectedIcon: false,
      onSelectionChanged: (s) => setState(() => _fahrenheit = s.first),
    ),
    const SizedBox(width: 8),
  ],
),
const CurrentConditions({super.key, required this.fahrenheit});

final bool fahrenheit; // chosen in ForecastScreen, received here

String temp(double c) =>
    fahrenheit ? '...' : '${c.round()} °C';

// e no ListView do ForecastScreen:
child: CurrentConditions(fahrenheit: _fahrenheit),
Faz Converte o ForecastScreen, põe o SegmentedButton nas actions da AppBar, acrescenta o parâmetro fahrenheit ao CurrentConditions e passa-lhe _fahrenheit. No lugar do '...', a conversão: °F = °C × 9 / 5 + 32, arredondada, com °F no fim.

Na app: carregas em °F e o 23 °C grande passa a 73 °F, a mínima a 63 °F e a máxima a 73 °F. A fila dos dias continua em °C.

3. Ciclo de vida do State

late final TextEditingController _search;

@override
void initState() {
  super.initState(); // once, before the first build
  _search = TextEditingController();
}

@override
void dispose() {
  _search.dispose(); // what initState creates, dispose frees
  super.dispose();
}
MétodoQuando correPara quê
initStateUma vez, quando o State é criadoCriar controllers, subscrever streams, lançar o primeiro pedido
buildSempre que é preciso desenharSó descrever a UI. Nada de pedidos de rede aqui
didUpdateWidgetQuando o pai reconstrói com parâmetros novosReagir a um parâmetro que mudou (nova localidade selecionada)
disposeUma vez, quando o widget sai da árvoreLibertar controllers, timers, subscrições, focus nodes
Pergunta à turma Porque é que o controller não pode ser criado dentro do build? E o que acontece se te esqueceres do dispose?

Para os testes: tudo o que crias em initState libertas em dispose; build pode correr muitas vezes e não deve ter efeitos secundários.

3. TextField e pesquisa: o que interessa

Passo 3: pesquisar ilhas

git stash -u
git checkout passo-2
flutter pub get
// Pista: Convert to StatefulWidget, e o controller do slide anterior
List<Island> get _filtered => islands
    .where((i) => '...')
    .toList();

// no build: uma Column com o TextField em cima e o GridView em baixo
child: TextField(
  controller: _search,
  decoration: InputDecoration(
    prefixIcon: const Icon(Icons.search),
    hintText: 'Pesquisar ilha',
    border: const OutlineInputBorder(),
    // suffixIcon: null sem texto; com texto, um IconButton que limpa
  ),
  onChanged: (_) => setState(() {}), // the text is in _search
),

// e o GridView mostra só as filtradas:
children: [for (final i in filtered) IslandCard(island: i)],
Faz Converte o IslandsGrid, cria o controller em initState e liberta-o em dispose, e escreve a condição do where no lugar do '...': o nome da ilha contém o texto, sem distinguir maiúsculas. Depois o botão para limpar (Icons.clear, _search.clear) e, sem resultados, o texto 'Nenhuma ilha encontrada'.

Na app: escreves "ter" e só fica a Terceira; aparece um X que limpa e volta a mostrar as nove; "xyz" mostra "Nenhuma ilha encontrada".

Pergunta à turma Escreve "sao". Porque é que não aparecem São Miguel nem São Jorge?

4. Form, TextFormField e validação

final _formKey = GlobalKey<FormState>(); // a field, never inside build
final _email = TextEditingController();

void _submit() {
  // validate() runs every validator and shows the errors
  if (_formKey.currentState!.validate()) {
    ScaffoldMessenger.of(context).showSnackBar(
      SnackBar(content: Text('Avisos enviados para ${_email.text}')),
    );
  }
}

child: Form(
  key: _formKey,
Pergunta à turma Hoje, sem validadores, o que faz "Subscrever" com os campos vazios? Porque é que o validate() devolve true?

Para os testes: um validator devolve null quando o valor é válido e uma String com o erro quando não é.

Passo 4: validar o formulário de avisos

git stash -u
git checkout passo-3
flutter pub get
// no Form, por baixo da key:
autovalidateMode: AutovalidateMode.onUserInteraction,

// no campo Nome:
validator: (v) {
  if (v == null || v.trim().isEmpty) return 'Obrigatório';
  // e com menos de 3 letras: 'Pelo menos 3 letras'
  return null; // null means valid
},

// no campo E-mail:
keyboardType: TextInputType.emailAddress,
// e um validator: 'Obrigatório', e 'E-mail inválido' sem @ ou sem ponto
Faz Troca os dois comentários dos campos pelos validator, acrescenta o autovalidateMode ao Form e o keyboardType ao e-mail.

Na app: "Subscrever" com os campos vazios mostra "Obrigatório" por baixo dos dois e não mostra o SnackBar; "Ana" e "ana@uac.pt" mostram "Avisos enviados para ana@uac.pt". No telemóvel, o campo do e-mail abre o teclado com @.

Onde ficámos

  1. DetailCard: StatefulWidget com _expanded, que abre e fecha ao toque (passo 1).
  2. ForecastScreen: guarda a unidade em _fahrenheit e passa-a por parâmetro ao CurrentConditions (passo 2). A fila dos dias ainda está em °C: é o custo de passar por parâmetro.
  3. IslandsGrid: pesquisa com controller criado em initState e libertado em dispose, lista filtrada calculada num getter (passo 3).
  4. AlertsForm: Form com GlobalKey, um validator por campo e autovalidateMode (passo 4).

Tudo isto é estado efémero, e setState chega. O branch passo-4 é o estado final: compara o teu código com ele.

Até amanhã

Próxima aula

  • Amanhã, quarta, 7 de outubro, das 16:00 às 18:00: navegação entre ecrãs e temas.
  • A unidade °C/°F vai precisar de chegar a um segundo ecrã, o de definições. Lembra-te do problema de passar por parâmetro.
  • O estado final de hoje fica no branch passo-4: git stash -u e depois git checkout passo-4.

Para quem quiser ler mais