Persistência é parte do comportamento
Preferências, tokens, catálogos e pedidos offline têm requisitos diferentes. A escolha correta começa com perguntas sobre volume, sensibilidade, consulta, consistência e ciclo de vida.
| Necessidade | Opção típica |
|---|---|
| Tema, idioma, flag simples | SharedPreferences |
| Objetos e cache chave-valor | Hive |
| Relações, filtros e transações | Drift/SQLite |
| Segredo pequeno | Armazenamento seguro da plataforma |
Não armazene token sensível em preferências comuns. Não use cache como única fonte de verdade sem uma política de validade.
SharedPreferences para configurações
final class PreferencesDataSource {
PreferencesDataSource(this._preferences);
final SharedPreferences _preferences;
bool get darkMode => _preferences.getBool('darkMode') ?? false;
Future<void> setDarkMode(bool enabled) {
return _preferences.setBool('darkMode', enabled);
}
}
Centralize chaves e valores padrão. A UI fala com um caso de uso ou controller, não diretamente com a biblioteca.
Hive para cache orientado a objetos
Hive é útil para leitura rápida por chave. Defina adapters, inicialização, versionamento e política de expiração. Um registro de cache deve carregar metadados:
final class CacheEntry<T> {
const CacheEntry({required this.value, required this.savedAt});
final T value;
final DateTime savedAt;
bool isFresh(Duration ttl, DateTime now) => now.difference(savedAt) <= ttl;
}
Nunca dependa do relógio real em testes; injete uma função de tempo.
Drift quando consultas importam
Drift combina SQLite com consultas tipadas e migrações explícitas. Use-o quando houver relações, filtros, transações ou grandes conjuntos locais.
class Products extends Table {
TextColumn get id => text()();
TextColumn get name => text()();
RealColumn get price => real()();
DateTimeColumn get updatedAt => dateTime()();
@override
Set<Column<Object>> get primaryKey => {id};
}
Migrações devem ser testadas com dados de versões anteriores. Perder o banco e recriar pode ser aceitável para cache descartável, mas não para trabalho offline ainda não sincronizado.
Offline-first é uma estratégia
Um fluxo comum:
- A UI observa o banco local.
- O repositório verifica validade.
- A API é consultada quando necessário.
- A resposta atualiza o banco em transação.
- A UI recebe a mudança pelo stream local.
Para escrita offline, use uma outbox local com identificador, operação, payload mínimo, tentativas e estado. Defina conflitos: servidor vence, cliente vence ou mesclagem por regra de domínio. Não esconda perda de dado atrás de “última gravação vence”.
Segurança e privacidade
Minimize dados locais, aplique logout que remove o que não deve sobreviver e evite logs com payloads. Em dispositivos comprometidos, criptografia local reduz exposição, mas não substitui autorização no servidor.
Prática guiada: faça o catálogo sobreviver sem rede
Continue no Sales Management. Neste módulo, preferência e catálogo terão armazenamentos diferentes. Não coloque JSON de produtos em SharedPreferences apenas porque a API é simples.
Checkpoint 1 — persista uma preferência com a API assíncrona
flutter pub add shared_preferences
Crie core/preferences/theme_preferences.dart:
import 'package:shared_preferences/shared_preferences.dart';
final class ThemePreferences {
ThemePreferences(this._preferences);
final SharedPreferencesAsync _preferences;
static const _darkModeKey = 'settings.dark_mode';
Future<bool> readDarkMode() async {
return await _preferences.getBool(_darkModeKey) ?? false;
}
Future<void> writeDarkMode(bool enabled) {
return _preferences.setBool(_darkModeKey, enabled);
}
}
Componha com ThemePreferences(SharedPreferencesAsync()). A API assíncrona consulta a fonte da plataforma e evita pressupor que um cache em memória está atualizado entre isolates ou engines.
Teste por meio de uma interface sua (ThemeSettingsStore) e um fake em memória. Não faça testes de domínio dependerem do plugin.
Checkpoint 2 — escolha banco por consulta, não por moda
Para o catálogo, vamos usar Drift porque precisamos observar lista, ordenar e preparar migrações:
flutter pub add drift drift_flutter
flutter pub add dev:drift_dev dev:build_runner
Crie core/database/app_database.dart:
import 'package:drift/drift.dart';
import 'package:drift_flutter/drift_flutter.dart';
part 'app_database.g.dart';
class Products extends Table {
TextColumn get id => text()();
TextColumn get name => text()();
RealColumn get price => real()();
DateTimeColumn get updatedAt => dateTime()();
@override
Set<Column<Object>> get primaryKey => {id};
}
@DriftDatabase(tables: [Products])
final class AppDatabase extends _$AppDatabase {
AppDatabase() : super(driftDatabase(name: 'sales_management'));
@override
int get schemaVersion => 1;
Stream<List<Product>> watchProducts() {
return (select(products)..orderBy([(row) => OrderingTerm.asc(row.name)]))
.watch();
}
Future<void> replaceProducts(List<ProductsCompanion> values) {
return transaction(() async {
await delete(products).go();
await batch((batch) => batch.insertAll(products, values));
});
}
}
Product nesse método é a classe gerada pelo Drift, não a entidade do domínio. Na camada data, use alias de import ou um mapper para evitar confusão.
Gere o arquivo:
dart run build_runner build --delete-conflicting-outputs
Não edite app_database.g.dart. Se a classe gerada não aparece, leia o primeiro erro do build_runner e confira part, anotações e nomes.
Checkpoint 3 — crie uma fronteira local e mapeie tipos
Defina ProductLocalDataSource com watchProducts() e replaceProducts(). A implementação Drift converte linhas geradas em ProductDto ou entidade por meio de mapper; o domínio não importa Drift.
Ao salvar resposta remota, grave toda a lista em uma transação. Sem transação, a UI pode observar um catálogo temporariamente vazio entre delete e insert.
Checkpoint 3: execute o aplicativo, carregue produtos, encerre completamente e abra novamente. A lista local deve aparecer antes da rede.
Checkpoint 4 — torne frescor uma política testável
Injete um relógio:
typedef Clock = DateTime Function();
final class CachePolicy {
const CachePolicy({required this.ttl, required this.clock});
final Duration ttl;
final Clock clock;
bool isFresh(DateTime savedAt) {
return clock().difference(savedAt) <= ttl;
}
}
O repositório segue uma sequência explícita:
- emite o stream do banco imediatamente;
- lê a data mais antiga/última sincronização;
- se fresco, não consulta a API automaticamente;
- se expirado, tenta a API;
- em sucesso, substitui o banco em transação;
- em falha com cache, mantém dados e marca “pode estar desatualizado”;
- em falha sem cache, expõe erro com retry.
TTL não apaga o dado. Ele decide quando tentar atualizar.
Checkpoint 5 — compare Hive conscientemente
O PDF original propõe Hive para cache chave-valor. Faça uma spike separada:
flutter pub add hive hive_flutter
Inicialize, abra uma box de teste, grave um mapa simples e leia por ID. Compare com Drift:
| Pergunta | Hive | Drift |
|---|---|---|
| acesso principal | chave | consulta SQL tipada |
| relações/filtros | manuais | naturais ao banco |
| geração | adapters quando usa tipos | schema e consultas gerados |
| migração | responsabilidade da aplicação | estratégia versionada explícita |
Antes de adotar, verifique manutenção, compatibilidade com sua versão do Dart e plano de migração. Uma biblioteca popular no material original não recebe aprovação automática para um produto novo.
Checkpoint 6 — teste upgrade e modo offline
Escreva testes para cache vazio, fresco, expirado, falha remota com cache e falha remota sem cache. Para migração, abra um banco no schema anterior com dados, atualize e confirme preservação; criar apenas um banco vazio não testa upgrade.
Manual:
- carregue dados online;
- encerre o app;
- desligue a rede;
- abra novamente;
- confirme dados, indicador de desatualização e ações seguras;
- religue e sincronize.
Execute build_runner, formatação, análise e testes.
Erros comuns
| Sintoma | Causa provável |
|---|---|
| banco abre vazio a cada inicialização | conexão foi criada em memória ou nome/caminho mudou |
| UI pisca vazia durante refresh | atualização não foi transacional |
| teste de TTL falha dependendo do horário | relógio real não foi injetado |
| migração “passa” mas perde dados | teste começou em banco vazio |
| logout mantém dados privados | política de limpeza não foi definida |
Prática autônoma: modele uma outbox para criação offline com ID, payload mínimo, tentativas, próxima execução e erro terminal. Defina uma política de conflito específica para preço.
Aprofundamento: validade, migração e conflito
Persistir um valor exige responder quem é a fonte de verdade, por quanto tempo o dado é válido e como versões antigas serão lidas. TTL é política de frescor, não mecanismo de exclusão. Um dado expirado pode continuar útil offline se a interface comunicar que está desatualizado.
Migração deve ser testada com banco criado pela versão anterior, não somente com banco vazio. Faça backup lógico quando a informação não puder ser reconstruída e mantenha transações curtas. Alterar schema sem estratégia transforma atualização da loja em risco de perda de dados.
Para escrita offline, a outbox precisa de identificador estável, tentativa, próximo horário, causa da última falha e estado terminal. Conflito não é “problema técnico” genérico: estoque, preço e cadastro podem exigir políticas diferentes.
Termos para revisar: source of truth, TTL, cache, durable data, migration, transaction, outbox e conflict resolution. Consulte a referência do curso.