PDM · DAW 2

Aula 16 · Semana 8 · Segunda, 26 de outubro de 2026 · 16:00 às 18:00

Persistência local e cache offline

Teoria: opções de persistência em mobile: chave-valor, SQLite, NoSQL local, ficheiros e armazenamento seguro; critérios de escolha. Prática: preferências com shared_preferences, cache de previsões numa base de dados local com estratégia cache-first, e token em armazenamento seguro.

Repositório: pdm-aula-16

No fim desta aula consegues

Onde guardar o quê

Chave-valor shared_preferences unidade °C/°F tema claro/escuro última localidade valores pequenos SQLite drift previsões em cache avisos histórico dados que se consultam Ficheiros path_provider imagens exportações, logs na sandbox da app bytes grandes Seguro flutter_secure_storage token de acesso refresh token Keychain / Keystore nunca em shared_preferences
Valores pequenos em preferências, dados que se consultam em SQLite, bytes em ficheiros, segredos no armazenamento seguro.
Tipo de dadoExemplo no Tempo AçoresFerramenta
Preferências chave-valorUnidade °C/°F, tema, última localidadeshared_preferences (SharedPreferences no Android, NSUserDefaults no iOS)
Dados estruturados com consultasCache de previsões e avisos, históricoSQLite: sqflite (SQL à mão) ou drift (tipado, reativo)
Documentos e objetosListas de objetos sem relaçõeshive, isar (NoSQL local, muito rápido)
FicheirosImagens, exportações, logspath_provider + dart:io
SegredosToken de acesso, refresh tokenflutter_secure_storage (Keychain no iOS, Keystore no Android)

Para os testes: preferências para valores pequenos, SQLite para dados que se consultam, secure storage para segredos.

Analogia Três sítios numa casa. A gaveta da cozinha (shared_preferences) guarda coisas pequenas e pouco importantes, à mão. O arquivo com pastas (SQLite) guarda muitos registos que se procuram por nome ou data. O cofre (Keychain e Keystore) guarda o token. Deixar o token na gaveta é deixar a chave de casa debaixo do tapete.

Sandbox: onde ficam os ficheiros da app

Para os testes: Documents persiste e vai no backup; tmp pode desaparecer a qualquer momento.

Keychain e Keystore: porque um token nunca vai para preferências

Para os testes: um token nunca vai para shared_preferences; vai para Keychain ou Keystore via flutter_secure_storage.

Cache-first, network-first, invalidação e migrações

Ecrã Repository Cache local (drift) Rede (API) 1 · pede a previsão 2 · lê a cache local 3 · dados + obtidoEm 4 · mostra já: «há 12 min» 5 · GET /v1/forecast, em fundo 6 · dados frescos 7 · grava na cache 8 · notifica: dados novos Sem rede: o passo 5 falha fica com a cache, marcada como antiga
Cache-first: o ecrã recebe primeiro o que há em cache e só depois os dados frescos. Sem rede, fica pela cache.
EstratégiaFluxoQuando
Cache-firstMostra a cache, pede à rede em fundo, atualizaPrevisões, listas: rapidez percebida
Network-firstPede à rede; se falhar, mostra a cacheDados críticos que mudam: saldo, stock
Cache-only / Network-onlyNunca vai à rede / nunca guardaMapas descarregados / pagamentos

Para os testes: cache-first mostra rápido e atualiza depois; network-first garante dados frescos; a invalidação por tempo decide quando a cache expira.

Analogia O frigorífico. Mostra-se já o que há no frigorífico, com a data no rótulo (obtidoEm), e entretanto vai-se às compras (o pedido à rede em fundo); quando se volta, substitui-se o que lá estava. Sem rede, come-se o que há, e o rótulo diz de quando é. A validade no rótulo é a invalidação por tempo: dentro do prazo não se vai às compras.

Tamanho, limpeza e o botão "Limpar dados"

Para os testes: a app tem de funcionar depois de o utilizador limpar os dados, exatamente como no primeiro arranque.

Repositório da aula

git clone https://github.com/pdm-daw-2026/pdm-aula-16.git
cd pdm-aula-16/starter
flutter pub get
flutter run
Antes de começar Nas definições do telemóvel, vê quanto espaço a app do starter ocupa. Volta a ver no fim da aula.

1. shared_preferences: unidade, tema, última localidade

class Definicoes {
  Definicoes(this._prefs);
  final SharedPreferences _prefs;

  static Future<Definicoes> carregar() async =>
      Definicoes(await SharedPreferences.getInstance());

  bool get usaFahrenheit => _prefs.getBool('usaFahrenheit') ?? false;
  ThemeMode get tema => ThemeMode.values.byName(_prefs.getString('tema') ?? 'system');
  int? get ultimaLocalidadeId => _prefs.getInt('ultimaLocalidadeId');

  Future<void> guardarUnidade({required bool fahrenheit}) =>
      _prefs.setBool('usaFahrenheit', fahrenheit);
  Future<void> guardarTema(ThemeMode modo) => _prefs.setString('tema', modo.name);
  Future<void> guardarUltimaLocalidade(int id) => _prefs.setInt('ultimaLocalidadeId', id);
}

// main.dart: carregar antes do runApp, para o tema estar certo no primeiro frame
void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  final definicoes = await Definicoes.carregar();
  runApp(Provider.value(value: definicoes, child: const TempoApp()));
}

1. Preferências: o que interessa

Exercício 1 Liga o switch °C/°F e o seletor de tema do ecrã de definições às preferências. Fecha a app (swipe, não só home) e abre: têm de estar como ficaram. Depois guarda a última localidade aberta e abre a app diretamente nela.

2. drift: a tabela de previsões

import 'package:drift/drift.dart';
import 'package:drift_flutter/drift_flutter.dart';

part 'base_dados.g.dart';

class Previsoes extends Table {
  IntColumn get localidadeId => integer()();
  TextColumn get json => text()();
  DateTimeColumn get obtidoEm => dateTime()();

  @override
  Set<Column> get primaryKey => {localidadeId};
}

@DriftDatabase(tables: [Previsoes])
class BaseDados extends _$BaseDados {
  BaseDados() : super(driftDatabase(name: 'tempo_acores'));

  @override
  int get schemaVersion => 1;
}
dart run build_runner build --delete-conflicting-outputs

2. drift: inserir, ler, apagar

extension PrevisoesCache on BaseDados {
  Future<Previsao?> lerPrevisao(int localidadeId) async {
    final linha = await (select(previsoes)
          ..where((p) => p.localidadeId.equals(localidadeId)))
        .getSingleOrNull();
    if (linha == null) return null;
    return Previsao.fromJson(jsonDecode(linha.json) as Map<String, dynamic>)
        .copyWith(obtidoEm: linha.obtidoEm);
  }

  Future<void> guardarPrevisao(Previsao p) =>
      into(previsoes).insertOnConflictUpdate(PrevisoesCompanion.insert(
        localidadeId: p.localidadeId,
        json: jsonEncode(p.toJson()),
        obtidoEm: DateTime.now(),
      ));

  Future<int> apagarAntigas({Duration maxIdade = const Duration(days: 7)}) =>
      (delete(previsoes)
            ..where((p) => p.obtidoEm.isSmallerThanValue(DateTime.now().subtract(maxIdade))))
          .go();
}

2. Base de dados: o que interessa

Exercício 2 Cria a tabela Avisos (id, json, obtidoEm) e a extensão AvisosCache com lerTodos, substituirTodos(List<Aviso>) e apagarAntigos. Volta a correr o build_runner. O teste test/avisos_cache_test.dart usa uma base em memória (NativeDatabase.memory()).

3. Repository cache-first

class PrevisaoRepositoryCache implements PrevisaoRepository {
  PrevisaoRepositoryCache(this._api, this._db);
  final PrevisaoApiDataSource _api;   // Future<Previsao> previsao(int id), da aula 12
  final BaseDados _db;
  static const validade = Duration(minutes: 30);

  @override
  Stream<Previsao> previsao(int localidadeId) async* {
    final emCache = await _db.lerPrevisao(localidadeId);
    if (emCache != null) yield emCache;                       // 1. mostra já o que há

    final valida = emCache != null &&
        DateTime.now().difference(emCache.obtidoEm) < validade;
    if (valida) return;                                       // 2. ainda fresca: fica

    try {
      final fresca = await _api.previsao(localidadeId);       // 3. vai à rede
      await _db.guardarPrevisao(fresca);
      yield fresca.copyWith(obtidoEm: DateTime.now());
    } catch (_) {
      if (emCache == null) rethrow;                           // sem cache e sem rede: erro
    }
  }
}

3. Cache-first: o que interessa

Future<void> carregar(int id) async {
  try {
    await for (final p in _repo.previsao(id)) {
      estado = Dados(p, obtidoEm: p.obtidoEm);
      notifyListeners();
    }
  } catch (e) {
    if (estado is! Dados) estado = Erro(mensagemPara(e));
    notifyListeners();
  }
}
Exercício 3 Regista PrevisaoRepositoryCache no main em vez do …Api, sem tocar no ecrã. Mostra "Atualizado há X min" no detalhe com a cor de aviso quando passa de 30 min.

4. Modo avião ligado: a app continua a mostrar dados

  1. Com rede, abre a previsão de duas localidades. Confirma no log que foram guardadas (guardarPrevisao chamado duas vezes).
  2. Liga o modo avião. Fecha a app com swipe (não só o botão home) e abre de novo.
  3. As duas localidades mostram os dados com "de há X min". Uma terceira, nunca aberta, mostra o erro humano "Sem ligação", não um crash.
  4. Desliga o modo avião e puxa para atualizar: "Atualizado agora". Passados 30 minutos, o próximo carregar vai sozinho à rede.
Exercício 4 Faz os quatro passos e grava um vídeo de 30 segundos do telemóvel (é o tipo de prova que vai no PR do projeto). Depois: chama apagarAntigas() no arranque da app e confirma que uma previsão de há 8 dias desaparece (muda a data no sqlite pelo DevTools ou por um teste).

5. flutter_secure_storage para o token

class TokenStorage {
  TokenStorage([FlutterSecureStorage? storage])
      : _storage = storage ??
            const FlutterSecureStorage(
              iOptions: IOSOptions(
                accessibility: KeychainAccessibility.first_unlock_this_device,
              ),
            );
  final FlutterSecureStorage _storage;
  static const _chave = 'access_token';

  Future<String?> ler() => _storage.read(key: _chave);
  Future<void> guardar(String token) => _storage.write(key: _chave, value: token);
  Future<void> apagar() => _storage.delete(key: _chave);
}
Exercício 5 Guarda o token obtido no arranque com TokenStorage e, no arranque seguinte, usa-o sem pedir outro. Confirma com adb shell run-as que não há nenhum ficheiro com o token em claro na pasta da app.

Até amanhã

Para fazer

  • Cache de avisos com o mesmo padrão: tabela, extensão, AvisosRepositoryCache com validade de 5 minutos, e "Atualizado há X min" no ecrã.
  • Lê a comparação drift vs sqflite vs hive e anota qual escolherias para o projeto e porquê.
  • Na quarta falamos de segurança e de segredos no repositório do grupo: confirma hoje que o .env.json está no .gitignore.

Para ler