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.

NecessidadeOpção típica
Tema, idioma, flag simplesSharedPreferences
Objetos e cache chave-valorHive
Relações, filtros e transaçõesDrift/SQLite
Segredo pequenoArmazenamento 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:

  1. A UI observa o banco local.
  2. O repositório verifica validade.
  3. A API é consultada quando necessário.
  4. A resposta atualiza o banco em transação.
  5. 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:

  1. emite o stream do banco imediatamente;
  2. lê a data mais antiga/última sincronização;
  3. se fresco, não consulta a API automaticamente;
  4. se expirado, tenta a API;
  5. em sucesso, substitui o banco em transação;
  6. em falha com cache, mantém dados e marca “pode estar desatualizado”;
  7. 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:

PerguntaHiveDrift
acesso principalchaveconsulta SQL tipada
relações/filtrosmanuaisnaturais ao banco
geraçãoadapters quando usa tiposschema e consultas gerados
migraçãoresponsabilidade da aplicaçãoestraté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:

  1. carregue dados online;
  2. encerre o app;
  3. desligue a rede;
  4. abra novamente;
  5. confirme dados, indicador de desatualização e ações seguras;
  6. religue e sincronize.

Execute build_runner, formatação, análise e testes.

Erros comuns

SintomaCausa provável
banco abre vazio a cada inicializaçãoconexão foi criada em memória ou nome/caminho mudou
UI pisca vazia durante refreshatualização não foi transacional
teste de TTL falha dependendo do horáriorelógio real não foi injetado
migração “passa” mas perde dadosteste começou em banco vazio
logout mantém dados privadospolí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.