Aula 16 · Semana 8 · Segunda, 26 de outubro de 2026 · 16:00 às 18:00
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.
shared_preferences.drift e mostrar a idade dos dados.| Tipo de dado | Exemplo no Tempo Açores | Ferramenta |
|---|---|---|
| Preferências chave-valor | Unidade °C/°F, tema, última localidade | shared_preferences (SharedPreferences no Android, NSUserDefaults no iOS) |
| Dados estruturados com consultas | Cache de previsões e avisos, histórico | SQLite: sqflite (SQL à mão) ou drift (tipado, reativo) |
| Documentos e objetos | Listas de objetos sem relações | hive, isar (NoSQL local, muito rápido) |
| Ficheiros | Imagens, exportações, logs | path_provider + dart:io |
| Segredos | Token de acesso, refresh token | flutter_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.
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./data/data/<package>/; iOS: Documents/, Library/, tmp/.path_provider abstrai isto: getApplicationDocumentsDirectory() persiste e vai para o backup; getApplicationSupportDirectory() persiste sem aparecer ao utilizador; getTemporaryDirectory() pode ser limpa pelo sistema.Documents/; o Android Auto Backup copia até 25 MB. Cache e tokens devem ficar de fora (android:fullBackupContent, isExcludedFromBackup).Para os testes: Documents persiste e vai no backup; tmp pode desaparecer a qualquer momento.
shared_preferences é um ficheiro XML (Android) ou plist (iOS) em texto. Num telemóvel com root lê-se com cat; nos backups vai em claro.flutter_secure_storage cifra os valores com AES e guarda a chave AES no Keystore.Para os testes: um token nunca vai para shared_preferences; vai para Keychain ou Keystore via flutter_secure_storage.
| Estratégia | Fluxo | Quando |
|---|---|---|
| Cache-first | Mostra a cache, pede à rede em fundo, atualiza | Previsões, listas: rapidez percebida |
| Network-first | Pede à rede; se falhar, mostra a cache | Dados críticos que mudam: saldo, stock |
| Cache-only / Network-only | Nunca vai à rede / nunca guarda | Mapas descarregados / pagamentos |
obtidoEm e compara: previsão válida 30 min, avisos 5 min, localidades 24 h.schemaVersion + MigrationStrategy no drift; nunca apagar dados do utilizador sem aviso.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.
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.tmp/ é apagado sob pressão de espaço.cached_network_image: 200 itens ou 7 dias).Para os testes: a app tem de funcionar depois de o utilizador limpar os dados, exatamente como no primeiro arranque.
git clone https://github.com/pdm-daw-2026/pdm-aula-16.git
cd pdm-aula-16/starter
flutter pub get
flutter run
starter/ é a app resiliente da aula 14 com shared_preferences, drift, drift_flutter e flutter_secure_storage já no pubspec.yaml, TODOs de 1 a 5 e testes a falhar.solucao/ é o estado final: a app funciona em modo avião e diz há quanto tempo os dados foram obtidos.drift gera código: dart run build_runner build depois de mexer nas tabelas. O starter já traz o ficheiro gerado.shared_preferences: unidade, tema, última localidadeclass 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()));
}
bool, int, double, String, List<String>. Nada de JSON grande aqui.getInstance(); escrita assíncrona. Por isso carregamos antes do runApp: o tema tem de estar certo no primeiro frame.WidgetsFlutterBinding.ensureInitialized() é obrigatório antes de qualquer plugin no main.name e lêem-se com values.byName. Nunca pelo índice: reordenar o enum partia as preferências guardadas.DefinicoesNotifier envolve esta classe e chama notifyListeners para a UI reagir. A versão 2.3+ do package tem também SharedPreferencesWithCache; a API antiga continua a funcionar.drift: a tabela de previsõesimport '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
drift: inserir, ler, apagarextension 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();
}
localidadeId) como chave primária e insertOnConflictUpdate para "guardar ou substituir".drift gera previsoes, PrevisoesCompanion e os tipos das colunas. Um erro de nome de coluna é erro de compilação, não de runtime como no sqflite.obtidoEm é o que permite dizer "de há 12 min" e invalidar: sem ele a cache é eterna.schemaVersion: 2 e uma MigrationStrategy com onUpgrade. Nunca apagar a base para "resolver".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()).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
}
}
}
Stream porque pode emitir duas vezes: a cache logo, os dados frescos depois. Com Future só havia uma resposta.async* e yield tornam isto legível: é código sequencial que emite valores pelo caminho.await for e notifica a cada emissão: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();
}
}
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.guardarPrevisao chamado duas vezes).carregar vai sozinho à rede.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).flutter_secure_storage para o tokenclass 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);
}
read, write, delete. Por baixo, Keychain no iOS e Keystore + AES no Android.first_unlock_this_device: acessível depois do primeiro desbloqueio e não migra para outro telemóvel via backup. É o que queres para um token.AuthClient lê daqui, renova em 401 e volta a guardar. Hoje só fica o token do arranque.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.AvisosRepositoryCache com validade de 5 minutos, e "Atualizado há X min" no ecrã..env.json está no .gitignore.sqflite, para comparar com o drift) e ficheiros.flutter_secure_storage.